Add bell icon and use better notif icon

This commit is contained in:
Jack Kingsman
2026-03-10 19:04:52 -07:00
parent bee273ab56
commit 3e7e0669c5
5 changed files with 66 additions and 16 deletions
+1
View File
@@ -270,6 +270,7 @@ export function App() {
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => { onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder); void handleSortOrderChange(sortOrder);
}, },
isConversationNotificationsEnabled,
}; };
const conversationPaneProps = { const conversationPaneProps = {
activeConversation, activeConversation,
+27 -13
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Bell,
CheckCheck, CheckCheck,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -36,6 +37,7 @@ type ConversationRow = {
name: string; name: string;
unreadCount: number; unreadCount: number;
isMention: boolean; isMention: boolean;
notificationsEnabled: boolean;
contact?: Contact; contact?: Contact;
}; };
@@ -93,6 +95,7 @@ interface SidebarProps {
sortOrder?: SortOrder; sortOrder?: SortOrder;
/** Callback when sort order changes */ /** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void; onSortOrderChange?: (order: SortOrder) => void;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
} }
export function Sidebar({ export function Sidebar({
@@ -111,6 +114,7 @@ export function Sidebar({
favorites, favorites,
sortOrder: sortOrderProp = 'recent', sortOrder: sortOrderProp = 'recent',
onSortOrderChange, onSortOrderChange,
isConversationNotificationsEnabled,
}: SidebarProps) { }: SidebarProps) {
const sortOrder = sortOrderProp; const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -405,6 +409,7 @@ export function Sidebar({
name: channel.name, name: channel.name,
unreadCount: getUnreadCount('channel', channel.key), unreadCount: getUnreadCount('channel', channel.key),
isMention: hasMention('channel', channel.key), isMention: hasMention('channel', channel.key),
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
}); });
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({ const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
@@ -414,6 +419,8 @@ export function Sidebar({
name: getContactDisplayName(contact.name, contact.public_key), name: getContactDisplayName(contact.name, contact.public_key),
unreadCount: getUnreadCount('contact', contact.public_key), unreadCount: getUnreadCount('contact', contact.public_key),
isMention: hasMention('contact', contact.public_key), isMention: hasMention('contact', contact.public_key),
notificationsEnabled:
isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false,
contact, contact,
}); });
@@ -446,19 +453,26 @@ export function Sidebar({
/> />
)} )}
<span className="name flex-1 truncate text-[13px]">{row.name}</span> <span className="name flex-1 truncate text-[13px]">{row.name}</span>
{row.unreadCount > 0 && ( <span className="ml-auto flex items-center gap-1">
<span {row.notificationsEnabled && (
className={cn( <span aria-label="Notifications enabled" title="Notifications enabled">
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center', <Bell className="h-3.5 w-3.5 text-muted-foreground" />
row.isMention </span>
? 'bg-badge-mention text-badge-mention-foreground' )}
: 'bg-badge-unread/90 text-badge-unread-foreground' {row.unreadCount > 0 && (
)} <span
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`} className={cn(
> 'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
{row.unreadCount} row.isMention
</span> ? 'bg-badge-mention text-badge-mention-foreground'
)} : 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</span>
</div> </div>
); );
@@ -4,7 +4,7 @@ import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState'; import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation'; const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = '/apple-touch-icon.png'; const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported'; type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>; type ConversationNotificationMap = Record<string, boolean>;
+35
View File
@@ -41,6 +41,7 @@ function renderSidebar(overrides?: {
favorites?: Favorite[]; favorites?: Favorite[];
lastMessageTimes?: ConversationTimes; lastMessageTimes?: ConversationTimes;
channels?: Channel[]; channels?: Channel[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) { }) {
const aliceName = 'Alice'; const aliceName = 'Alice';
const publicChannel = makeChannel('AA'.repeat(16), 'Public'); const publicChannel = makeChannel('AA'.repeat(16), 'Public');
@@ -76,6 +77,7 @@ function renderSidebar(overrides?: {
favorites={favorites} favorites={favorites}
sortOrder="recent" sortOrder="recent"
onSortOrderChange={vi.fn()} onSortOrderChange={vi.fn()}
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/> />
); );
@@ -218,4 +220,37 @@ describe('Sidebar section summaries', () => {
const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id); const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id);
expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key])); expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key]));
}); });
it('shows a notification bell for conversations with notifications enabled', () => {
const { aliceName } = renderSidebar({
unreadCounts: {},
isConversationNotificationsEnabled: (type, id) =>
(type === 'contact' && id === '11'.repeat(32)) ||
(type === 'channel' && id === 'BB'.repeat(16)),
});
const aliceRow = screen.getByText(aliceName).closest('div');
const flightRow = screen.getByText('#flight').closest('div');
if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows');
expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
});
it('keeps the notification bell to the left of the unread pill when both are present', () => {
const { aliceName } = renderSidebar({
unreadCounts: {
[getStateKey('contact', '11'.repeat(32))]: 3,
},
isConversationNotificationsEnabled: (type, id) =>
type === 'contact' && id === '11'.repeat(32),
});
const aliceRow = screen.getByText(aliceName).closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
const bell = within(aliceRow).getByLabelText('Notifications enabled');
const unread = within(aliceRow).getByText('3');
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
}); });
@@ -81,7 +81,7 @@ describe('useBrowserNotifications', () => {
); );
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', { expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: '/apple-touch-icon.png', icon: '/favicon-256x256.png',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
}); });
}); });
@@ -110,7 +110,7 @@ describe('useBrowserNotifications', () => {
expect(window.Notification).toHaveBeenCalledTimes(2); expect(window.Notification).toHaveBeenCalledTimes(2);
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', { expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
body: 'hello room', body: 'hello room',
icon: '/apple-touch-icon.png', icon: '/favicon-256x256.png',
tag: 'meshcore-message-42', tag: 'meshcore-message-42',
}); });
}); });