Add notifications

This commit is contained in:
Jack Kingsman
2026-03-10 18:47:03 -07:00
parent 1842bcf43e
commit bee273ab56
12 changed files with 539 additions and 6 deletions
@@ -14,7 +14,11 @@ const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
onTrace: noop,
onToggleNotifications: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
@@ -120,6 +124,26 @@ describe('ChatHeader key visibility', () => {
expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument();
});
it('shows enabled notification state and toggles when clicked', () => {
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
const onToggleNotifications = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
notificationsEnabled
onToggleNotifications={onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('prompts for regional override when globe button is clicked', () => {
const key = 'CD'.repeat(16);
const channel = makeChannel(key, '#flightless', true);
@@ -99,6 +99,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
rawPackets: [],
config,
health,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
@@ -122,6 +125,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
};
}
@@ -99,10 +99,14 @@ const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
@@ -190,6 +194,21 @@ describe('RepeaterDashboard', () => {
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows enabled notification state and toggles when clicked', () => {
render(
<RepeaterDashboard
{...defaultProps}
notificationsEnabled
onToggleNotifications={defaultProps.onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(defaultProps.onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';
@@ -0,0 +1,151 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
import type { Message } from '../types';
const mocks = vi.hoisted(() => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
const incomingChannelMessage: Message = {
id: 42,
type: 'CHAN',
conversation_key: 'ab'.repeat(16),
text: 'hello room',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: 'cd'.repeat(32),
outgoing: false,
acked: 0,
sender_name: 'Alice',
channel_name: '#flightless',
};
describe('useBrowserNotifications', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.location.hash = '';
vi.spyOn(window, 'open').mockReturnValue(null);
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
});
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
const NotificationMock = vi.fn().mockImplementation(function (this: Record<string, unknown>) {
this.close = vi.fn();
this.onclick = null;
});
Object.assign(NotificationMock, {
permission: 'granted',
requestPermission: vi.fn(async () => 'granted'),
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: NotificationMock,
});
});
it('stores notification opt-in per conversation', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(
result.current.isConversationNotificationsEnabled(
'channel',
incomingChannelMessage.conversation_key
)
).toBe(true);
expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe(
false
);
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',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
});
});
it('only sends desktop notifications for opted-in conversations', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
result.current.notifyIncomingMessage({
...incomingChannelMessage,
id: 43,
conversation_key: '34'.repeat(16),
channel_name: '#elsewhere',
});
});
expect(window.Notification).toHaveBeenCalledTimes(2);
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
body: 'hello room',
icon: '/apple-touch-icon.png',
tag: 'meshcore-message-42',
});
});
it('notification click deep-links to the conversation hash', async () => {
const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {});
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
});
const notificationInstance = (window.Notification as unknown as ReturnType<typeof vi.fn>).mock
.instances[1] as {
onclick: (() => void) | null;
close: ReturnType<typeof vi.fn>;
};
act(() => {
notificationInstance.onclick?.();
});
expect(window.open).toHaveBeenCalledWith(
`${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`,
'_self'
);
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
});
});
@@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
updateMessageAck: vi.fn(),
notifyIncomingMessage: vi.fn(),
...overrides,
},
fns: {
@@ -163,6 +164,7 @@ describe('useRealtimeAppState', () => {
`contact-${incomingDm.conversation_key}`,
true
);
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
});
it('deleting the active contact clears it and marks fallback recovery pending', () => {