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

View File

@@ -13,6 +13,7 @@ import {
useConversationActions,
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
} from './hooks';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
@@ -22,6 +23,13 @@ import type { Conversation, RawPacket } from './types';
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const {
notificationsSupported,
notificationsPermission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const {
showNewMessage,
showSettings,
@@ -202,6 +210,7 @@ export function App() {
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
});
const {
handleSendMessage,
@@ -237,7 +246,10 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const statusProps = { health, config };
const statusProps = {
health,
config,
};
const sidebarProps = {
contacts,
channels,
@@ -289,6 +301,21 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
notificationsSupported,
notificationsPermission,
notificationsEnabled:
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id)
: false,
onToggleNotifications: () => {
if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') {
void toggleConversationNotifications(
activeConversation.type,
activeConversation.id,
activeConversation.name
);
}
},
};
const searchProps = {
contacts,

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -14,7 +14,11 @@ interface ChatHeaderProps {
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
@@ -29,7 +33,11 @@ export function ChatHeader({
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
onTrace,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
@@ -198,6 +206,35 @@ export function ChatHeader({
<Route className="h-4 w-4" aria-hidden="true" />
</button>
)}
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

View File

@@ -31,6 +31,9 @@ interface ConversationPaneProps {
rawPackets: RawPacket[];
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
messagesLoading: boolean;
@@ -54,6 +57,7 @@ interface ConversationPaneProps {
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -69,6 +73,9 @@ export function ConversationPane({
rawPackets,
config,
health,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
messagesLoading,
@@ -92,6 +99,7 @@ export function ConversationPane({
onLoadNewer,
onJumpToBottom,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
@@ -155,10 +163,14 @@ export function ConversationPane({
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
/>
@@ -174,7 +186,11 @@ export function ConversationPane({
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onDeleteChannel={onDeleteChannel}

View File

@@ -1,6 +1,6 @@
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Route, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -25,10 +25,14 @@ interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
}
@@ -37,10 +41,14 @@ export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
radioLat,
radioLon,
radioName,
onTrace,
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
@@ -120,6 +128,35 @@ export function RepeaterDashboard({
>
<Route className="h-4 w-4" aria-hidden="true" />
</button>
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onToggleFavorite('contact', conversation.id)}

View File

@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useState } from 'react';
import { toast } from '../components/ui/sonner';
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';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
return getStateKey(type, id);
}
function readStoredEnabledMap(): ConversationNotificationMap {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
);
} catch {
return {};
}
}
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
}
function getInitialPermission(): NotificationPermissionState {
if (typeof window === 'undefined' || !('Notification' in window)) {
return 'unsupported';
}
return window.Notification.permission;
}
function shouldShowDesktopNotification(): boolean {
if (typeof document === 'undefined') {
return false;
}
return document.visibilityState !== 'visible' || !document.hasFocus();
}
function getMessageConversationNotificationKey(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
return getConversationNotificationKey('contact', message.conversation_key);
}
if (message.type === 'CHAN' && message.conversation_key) {
return getConversationNotificationKey('channel', message.conversation_key);
}
return null;
}
function buildNotificationTitle(message: Message): string {
if (message.type === 'PRIV') {
return message.sender_name
? `New message from ${message.sender_name}`
: `New message from ${message.conversation_key.slice(0, 12)}`;
}
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
return `New message in ${roomName}`;
}
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
}
function buildMessageNotificationHash(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
const label = message.sender_name || message.conversation_key.slice(0, 12);
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
if (message.type === 'CHAN' && message.conversation_key) {
const label = message.channel_name || message.conversation_key.slice(0, 8);
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
return null;
}
export function useBrowserNotifications() {
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
const [enabledByConversation, setEnabledByConversation] =
useState<ConversationNotificationMap>(readStoredEnabledMap);
useEffect(() => {
setPermission(getInitialPermission());
}, []);
const isConversationNotificationsEnabled = useCallback(
(type: 'channel' | 'contact', id: string) =>
permission === 'granted' &&
enabledByConversation[getConversationNotificationKey(type, id)] === true,
[enabledByConversation, permission]
);
const toggleConversationNotifications = useCallback(
async (type: 'channel' | 'contact', id: string, label: string) => {
const conversationKey = getConversationNotificationKey(type, id);
if (enabledByConversation[conversationKey]) {
setEnabledByConversation((prev) => {
const next = { ...prev };
delete next[conversationKey];
writeStoredEnabledMap(next);
return next;
});
toast.success(`${label} notifications disabled`);
return;
}
if (permission === 'unsupported') {
toast.error('Browser notifications unavailable', {
description: 'This browser does not support desktop notifications.',
});
return;
}
if (permission === 'denied') {
toast.error('Browser notifications blocked', {
description: 'Allow notifications in your browser settings, then try again.',
});
return;
}
const nextPermission = await window.Notification.requestPermission();
setPermission(nextPermission);
if (nextPermission === 'granted') {
setEnabledByConversation((prev) => {
const next = {
...prev,
[conversationKey]: true,
};
writeStoredEnabledMap(next);
return next;
});
new window.Notification(buildPreviewNotificationTitle(type, label), {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-notification-preview-${conversationKey}`,
});
toast.success(`${label} notifications enabled`);
return;
}
toast.error('Browser notifications not enabled', {
description:
nextPermission === 'denied'
? 'Permission was denied by the browser.'
: 'Permission request was dismissed.',
});
},
[enabledByConversation, permission]
);
const notifyIncomingMessage = useCallback(
(message: Message) => {
const conversationKey = getMessageConversationNotificationKey(message);
if (
permission !== 'granted' ||
!conversationKey ||
enabledByConversation[conversationKey] !== true ||
!shouldShowDesktopNotification()
) {
return;
}
const notification = new window.Notification(buildNotificationTitle(message), {
body: message.text,
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-message-${message.id}`,
});
notification.onclick = () => {
const hash = buildMessageNotificationHash(message);
if (hash) {
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
}
window.focus();
notification.close();
};
},
[enabledByConversation, permission]
);
return {
notificationsSupported: permission !== 'unsupported',
notificationsPermission: permission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
};
}

View File

@@ -44,6 +44,7 @@ interface UseRealtimeAppStateArgs {
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
maxRawPackets?: number;
}
@@ -103,6 +104,7 @@ export function useRealtimeAppState({
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -180,18 +182,19 @@ export function useRealtimeAppState({
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNew) {
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
@@ -203,6 +206,10 @@ export function useRealtimeAppState({
}
}
}
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
}
},
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
@@ -259,6 +266,7 @@ export function useRealtimeAppState({
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);
}

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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);
});
});

View File

@@ -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', () => {