mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 01:11:32 +02:00
Add notifications
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user