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',
});
});