From 3e7e0669c5bb59e09b285f9bde77ef9fe4b05b35 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 10 Mar 2026 19:04:52 -0700 Subject: [PATCH] Add bell icon and use better notif icon --- frontend/src/App.tsx | 1 + frontend/src/components/Sidebar.tsx | 40 +++++++++++++------ frontend/src/hooks/useBrowserNotifications.ts | 2 +- frontend/src/test/sidebar.test.tsx | 35 ++++++++++++++++ .../src/test/useBrowserNotifications.test.ts | 4 +- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 038d87d..daf0901 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -270,6 +270,7 @@ export function App() { onSortOrderChange: (sortOrder: 'recent' | 'alpha') => { void handleSortOrderChange(sortOrder); }, + isConversationNotificationsEnabled, }; const conversationPaneProps = { activeConversation, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index fc7d3bf..298c6b0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + Bell, CheckCheck, ChevronDown, ChevronRight, @@ -36,6 +37,7 @@ type ConversationRow = { name: string; unreadCount: number; isMention: boolean; + notificationsEnabled: boolean; contact?: Contact; }; @@ -93,6 +95,7 @@ interface SidebarProps { sortOrder?: SortOrder; /** Callback when sort order changes */ onSortOrderChange?: (order: SortOrder) => void; + isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; } export function Sidebar({ @@ -111,6 +114,7 @@ export function Sidebar({ favorites, sortOrder: sortOrderProp = 'recent', onSortOrderChange, + isConversationNotificationsEnabled, }: SidebarProps) { const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); @@ -405,6 +409,7 @@ export function Sidebar({ name: channel.name, unreadCount: getUnreadCount('channel', channel.key), isMention: hasMention('channel', channel.key), + notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false, }); const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({ @@ -414,6 +419,8 @@ export function Sidebar({ name: getContactDisplayName(contact.name, contact.public_key), unreadCount: getUnreadCount('contact', contact.public_key), isMention: hasMention('contact', contact.public_key), + notificationsEnabled: + isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false, contact, }); @@ -446,19 +453,26 @@ export function Sidebar({ /> )} {row.name} - {row.unreadCount > 0 && ( - - {row.unreadCount} - - )} + + {row.notificationsEnabled && ( + + + + )} + {row.unreadCount > 0 && ( + + {row.unreadCount} + + )} + ); diff --git a/frontend/src/hooks/useBrowserNotifications.ts b/frontend/src/hooks/useBrowserNotifications.ts index 6951883..395be43 100644 --- a/frontend/src/hooks/useBrowserNotifications.ts +++ b/frontend/src/hooks/useBrowserNotifications.ts @@ -4,7 +4,7 @@ import type { Message } from '../types'; import { getStateKey } from '../utils/conversationState'; 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 ConversationNotificationMap = Record; diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index ab01c57..b8422d0 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -41,6 +41,7 @@ function renderSidebar(overrides?: { favorites?: Favorite[]; lastMessageTimes?: ConversationTimes; channels?: Channel[]; + isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; }) { const aliceName = 'Alice'; const publicChannel = makeChannel('AA'.repeat(16), 'Public'); @@ -76,6 +77,7 @@ function renderSidebar(overrides?: { favorites={favorites} sortOrder="recent" onSortOrderChange={vi.fn()} + isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled} /> ); @@ -218,4 +220,37 @@ describe('Sidebar section summaries', () => { const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id); 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(); + }); }); diff --git a/frontend/src/test/useBrowserNotifications.test.ts b/frontend/src/test/useBrowserNotifications.test.ts index 00eb4ca..530ce21 100644 --- a/frontend/src/test/useBrowserNotifications.test.ts +++ b/frontend/src/test/useBrowserNotifications.test.ts @@ -81,7 +81,7 @@ describe('useBrowserNotifications', () => { ); 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.', - icon: '/apple-touch-icon.png', + icon: '/favicon-256x256.png', tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, }); }); @@ -110,7 +110,7 @@ describe('useBrowserNotifications', () => { expect(window.Notification).toHaveBeenCalledTimes(2); expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', { body: 'hello room', - icon: '/apple-touch-icon.png', + icon: '/favicon-256x256.png', tag: 'meshcore-message-42', }); });