From df5cef172b5f8cf1dc928dbe4b4cc2fbca1524b7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 16:55:03 -0700 Subject: [PATCH 1/2] RG-T117 Unit chat fix --- jest-setup.ts | 19 +++++++++++++++++++ .../chat/new-conversation-sheet.tsx | 5 +++-- .../__tests__/use-signalr-lifecycle.test.tsx | 11 +++++++++++ src/lib/__tests__/navigation.test.ts | 10 ++++++++++ .../__tests__/push-notification.test.ts | 6 ++++++ .../chat/__tests__/hub-invoke-args.test.ts | 13 +++++++++++++ src/stores/chat/store.ts | 6 ++++++ src/translations/ar.json | 2 +- src/translations/de.json | 2 +- src/translations/en.json | 2 +- src/translations/es.json | 2 +- src/translations/fr.json | 2 +- src/translations/it.json | 2 +- src/translations/pl.json | 2 +- src/translations/sv.json | 2 +- src/translations/uk.json | 2 +- 16 files changed, 77 insertions(+), 11 deletions(-) diff --git a/jest-setup.ts b/jest-setup.ts index 8fde1fb5..5b46e1e8 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,5 +1,24 @@ import '@testing-library/react-native/extend-expect'; +// Mock react-native-safe-area-context — its source build reads StyleSheet at import time, +// which explodes in suites that stub react-native with a minimal factory (e.g. navigation tests). +jest.mock('react-native-safe-area-context', () => { + const React = require('react'); + + const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children); + + return { + SafeAreaView, + SafeAreaProvider: ({ children }: any) => children, + useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })), + useSafeAreaFrame: jest.fn(() => ({ x: 0, y: 0, width: 375, height: 667 })), + initialWindowMetrics: { + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + frame: { x: 0, y: 0, width: 375, height: 667 }, + }, + }; +}); + // Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest jest.mock('@sentry/react-native', () => ({ captureException: jest.fn(), diff --git a/src/components/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx index 7365d6de..80c7e1e9 100644 --- a/src/components/chat/new-conversation-sheet.tsx +++ b/src/components/chat/new-conversation-sheet.tsx @@ -110,9 +110,10 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo ); const createGroup = useCallback(async () => { - if (!groupName.trim() || selected.size === 0) return; + if (selected.size === 0) return; setSubmitting(true); try { + // Name is optional — the server auto-names the group after its members when empty. const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) }); if (response.Data?.ChatChannelId) { onCreated(response.Data.ChatChannelId); @@ -191,7 +192,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo )} {mode === 'group' ? ( - diff --git a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx index dcfa8e09..e0018608 100644 --- a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx +++ b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx @@ -17,6 +17,8 @@ describe('useSignalRLifecycle', () => { const mockDisconnectUpdateHub = jest.fn(); const mockConnectGeolocationHub = jest.fn(); const mockDisconnectGeolocationHub = jest.fn(); + const mockConnectChatHub = jest.fn(); + const mockDisconnectChatHub = jest.fn(); // Create shared state for app lifecycle that can be updated let appLifecycleState = { @@ -48,15 +50,21 @@ describe('useSignalRLifecycle', () => { disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, } as any) : { connectUpdateHub: mockConnectUpdateHub, disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, } as any); // Also mock getState for direct store access @@ -65,8 +73,11 @@ describe('useSignalRLifecycle', () => { disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, }); // Mock useAppLifecycle to return shared state diff --git a/src/lib/__tests__/navigation.test.ts b/src/lib/__tests__/navigation.test.ts index 33950357..29b14657 100644 --- a/src/lib/__tests__/navigation.test.ts +++ b/src/lib/__tests__/navigation.test.ts @@ -2,6 +2,16 @@ import { Platform, Linking } from 'react-native'; import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; import { openMapsWithDirections, openMapsWithAddress } from '../navigation'; +// Mock expo-router — the real module pulls in its vendored react-navigation tree, which +// needs far more of react-native than the minimal stub below provides. +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + replace: jest.fn(), + navigate: jest.fn(), + }, +})); + // Mock React Native modules jest.mock('react-native', () => ({ Platform: { diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index 0f8e43af..b68b8d90 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -23,6 +23,12 @@ jest.mock('react-native', () => ({ }, })); +// Mock the navigation lib — the real module imports expo-router, whose import chain +// needs far more of react-native/expo than the minimal stubs above provide. +jest.mock('@/lib/navigation', () => ({ + routerPushWithRetry: jest.fn().mockResolvedValue(undefined), +})); + // Mock other dependencies jest.mock('@/lib/logging', () => ({ logger: { diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index 02fc3f53..5fc6ffa5 100644 --- a/src/stores/chat/__tests__/hub-invoke-args.test.ts +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -8,6 +8,7 @@ * JoinChannel(string channelId, int? asUnitId) * Typing(string channelId, string displayName, bool isTyping, int? asUnitId) * MarkRead(string channelId, long seq, int? asUnitId) + * SetActiveChannel(string channelId, int? asUnitId) */ const mockInvoke = jest.fn().mockResolvedValue(undefined); @@ -75,6 +76,18 @@ describe('chat hub invocations', () => { expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', 42); }); + it('sends both SetActiveChannel arguments', () => { + useChatStore.getState().setActiveChannel('channel-1'); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', 42); + }); + + it('clears the active channel marker with a null channelId and the unit id', () => { + useChatStore.getState().setActiveChannel(null); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, 42); + }); + it('sends all four Typing arguments in hub order', () => { useChatStore.getState().sendTyping('channel-1', true); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index a2cd3934..7420c7e3 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -280,6 +280,10 @@ export const useChatStore = create()( setActiveChannel: (channelId: string | null) => { set({ activeChannelId: channelId }); + // Tell the hub which conversation is on screen so it suppresses chat push + // notifications for that channel — including the unit-device push when the + // active unit id is supplied. A null channelId clears the marker. + void safeInvoke('SetActiveChannel', channelId ?? null, activeUnitIdNumber() ?? null); }, // ------------------------------------------------------------------ @@ -853,6 +857,8 @@ export const useChatStore = create()( if (activeChannelId) { void get().joinChannel(activeChannelId); void get().loadNewerMessages(activeChannelId); + // Re-assert the active-channel marker so push suppression survives reconnects. + void safeInvoke('SetActiveChannel', activeChannelId, activeUnitIdNumber() ?? null); } }, diff --git a/src/translations/ar.json b/src/translations/ar.json index f7ed5dd6..30706d40 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -350,7 +350,7 @@ "flag_reason": "لماذا تبلغ عن هذا؟", "flag_sensitive": "معلومات حساسة", "flag_spam": "بريد عشوائي", - "group_name": "اسم المجموعة", + "group_name": "اسم المجموعة (اختياري)", "is_typing": "{{name}} يكتب...", "load_people_failed": "تعذر تحميل الأشخاص", "message_deleted": "تم حذف هذه الرسالة", diff --git a/src/translations/de.json b/src/translations/de.json index 6679c7a1..1c3d6d74 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -350,7 +350,7 @@ "flag_reason": "Warum melden Sie dies?", "flag_sensitive": "Sensible Informationen", "flag_spam": "Spam", - "group_name": "Gruppenname", + "group_name": "Gruppenname (optional)", "is_typing": "{{name}} schreibt...", "load_people_failed": "Personen konnten nicht geladen werden", "message_deleted": "Diese Nachricht wurde gelöscht", diff --git a/src/translations/en.json b/src/translations/en.json index 81bddae1..7572f2bb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -350,7 +350,7 @@ "flag_reason": "Why are you reporting this?", "flag_sensitive": "Sensitive information", "flag_spam": "Spam", - "group_name": "Group name", + "group_name": "Group name (optional)", "is_typing": "{{name}} is typing...", "load_people_failed": "Could not load people", "message_deleted": "This message was deleted", diff --git a/src/translations/es.json b/src/translations/es.json index db3eb4fb..31fb4763 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -350,7 +350,7 @@ "flag_reason": "¿Por qué informas de esto?", "flag_sensitive": "Información sensible", "flag_spam": "Spam", - "group_name": "Nombre del grupo", + "group_name": "Nombre del grupo (opcional)", "is_typing": "{{name}} está escribiendo...", "load_people_failed": "No se pudieron cargar las personas", "message_deleted": "Este mensaje fue eliminado", diff --git a/src/translations/fr.json b/src/translations/fr.json index 3ffda709..b63fd454 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -350,7 +350,7 @@ "flag_reason": "Pourquoi le signalez-vous ?", "flag_sensitive": "Informations sensibles", "flag_spam": "Spam", - "group_name": "Nom du groupe", + "group_name": "Nom du groupe (optionnel)", "is_typing": "{{name}} est en train d'écrire...", "load_people_failed": "Impossible de charger les personnes", "message_deleted": "Ce message a été supprimé", diff --git a/src/translations/it.json b/src/translations/it.json index 498c2ed8..0506f7ac 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -350,7 +350,7 @@ "flag_reason": "Perché lo stai segnalando?", "flag_sensitive": "Informazioni sensibili", "flag_spam": "Spam", - "group_name": "Nome del gruppo", + "group_name": "Nome del gruppo (opzionale)", "is_typing": "{{name}} sta scrivendo...", "load_people_failed": "Impossibile caricare le persone", "message_deleted": "Questo messaggio è stato eliminato", diff --git a/src/translations/pl.json b/src/translations/pl.json index 300ebdad..71938226 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -350,7 +350,7 @@ "flag_reason": "Dlaczego to zgłaszasz?", "flag_sensitive": "Informacje poufne", "flag_spam": "Spam", - "group_name": "Nazwa grupy", + "group_name": "Nazwa grupy (opcjonalnie)", "is_typing": "{{name}} pisze...", "load_people_failed": "Nie można załadować osób", "message_deleted": "Ta wiadomość została usunięta", diff --git a/src/translations/sv.json b/src/translations/sv.json index 2925e4f2..39bd8518 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -350,7 +350,7 @@ "flag_reason": "Varför rapporterar du detta?", "flag_sensitive": "Känslig information", "flag_spam": "Skräppost", - "group_name": "Gruppnamn", + "group_name": "Gruppnamn (valfritt)", "is_typing": "{{name}} skriver...", "load_people_failed": "Det gick inte att ladda personer", "message_deleted": "Detta meddelande har raderats", diff --git a/src/translations/uk.json b/src/translations/uk.json index 76a46afb..93dd5ee2 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -350,7 +350,7 @@ "flag_reason": "Чому ви це повідомляєте?", "flag_sensitive": "Конфіденційна інформація", "flag_spam": "Спам", - "group_name": "Назва групи", + "group_name": "Назва групи (необов'язково)", "is_typing": "{{name}} пише...", "load_people_failed": "Не вдалося завантажити людей", "message_deleted": "Це повідомлення було видалено", From 1eaffc73677bd1079676ce395ffc2bc49d411c35 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 20:05:41 -0700 Subject: [PATCH 2/2] RG-T117 PR#266 fixes --- .../chat/__tests__/hub-invoke-args.test.ts | 49 +++++++++++++++++++ src/stores/chat/store.ts | 28 +++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index 5fc6ffa5..b7e46ea8 100644 --- a/src/stores/chat/__tests__/hub-invoke-args.test.ts +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -127,6 +127,55 @@ describe('chat hub invocations', () => { }); }); +describe('active-channel marker resynchronization', () => { + // syncActiveChannelMarker settles on the microtask queue; two ticks drain it. + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + beforeEach(async () => { + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + useChatStore.getState().reset(); + await flush(); + mockInvoke.mockClear(); + }); + + it('re-asserts a non-null marker on reconnect', async () => { + useChatStore.getState().setActiveChannel('channel-1'); + await flush(); + mockInvoke.mockClear(); + + useChatStore.getState().handleChatConnected(); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', 42); + }); + + it('retries a null marker that failed to send once reconnected', async () => { + mockInvoke.mockRejectedValue(new Error('disconnected')); + useChatStore.getState().setActiveChannel(null); + await flush(); + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + + useChatStore.getState().handleChatConnected(); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, 42); + }); + + it('does not resend a null marker the hub already confirmed', async () => { + useChatStore.getState().setActiveChannel(null); + await flush(); + mockInvoke.mockClear(); + + useChatStore.getState().handleChatConnected(); + await flush(); + + expect(mockInvoke).not.toHaveBeenCalledWith('chatHub', 'SetActiveChannel', expect.anything(), expect.anything()); + }); +}); + describe('incoming message normalization', () => { beforeEach(() => { useChatStore.setState({ messagesByChannel: {}, channels: [] }); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 7420c7e3..0ad03e4c 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -245,6 +245,23 @@ async function safeInvoke(method: string, ...args: unknown[]): Promise { } } +/** Active-channel marker the hub has not confirmed yet. Kept — null included, since + * null means "clear the marker" — until an invoke succeeds, so a send that failed + * while offline can be replayed on reconnect. */ +let pendingActiveChannelSync: { channelId: string | null } | null = null; + +async function syncActiveChannelMarker(channelId: string | null): Promise { + const marker = { channelId }; + pendingActiveChannelSync = marker; + try { + await signalRService.invoke(Env.CHAT_HUB_NAME, 'SetActiveChannel', channelId, activeUnitIdNumber() ?? null); + // Only clear if no newer marker superseded this one while in flight. + if (pendingActiveChannelSync === marker) pendingActiveChannelSync = null; + } catch (error) { + logger.debug({ message: 'chat: invoke SetActiveChannel skipped', context: { error } }); + } +} + export const useChatStore = create()( persist( (set, get) => ({ @@ -283,7 +300,7 @@ export const useChatStore = create()( // Tell the hub which conversation is on screen so it suppresses chat push // notifications for that channel — including the unit-device push when the // active unit id is supplied. A null channelId clears the marker. - void safeInvoke('SetActiveChannel', channelId ?? null, activeUnitIdNumber() ?? null); + void syncActiveChannelMarker(channelId ?? null); }, // ------------------------------------------------------------------ @@ -857,8 +874,12 @@ export const useChatStore = create()( if (activeChannelId) { void get().joinChannel(activeChannelId); void get().loadNewerMessages(activeChannelId); - // Re-assert the active-channel marker so push suppression survives reconnects. - void safeInvoke('SetActiveChannel', activeChannelId, activeUnitIdNumber() ?? null); + } + // Re-assert the active-channel marker so push suppression survives reconnects. + // A pending null (screen closed while offline) is flushed too, so the server + // stops suppressing push for a channel no longer on screen. + if (activeChannelId !== null || pendingActiveChannelSync !== null) { + void syncActiveChannelMarker(activeChannelId); } }, @@ -868,6 +889,7 @@ export const useChatStore = create()( lastTypingSentAt.clear(); lastMarkedSeq.clear(); pendingChatbotMessages.clear(); + pendingActiveChannelSync = null; clearChatbotTypingTimeout(); if (outboxDrainTimer) { clearTimeout(outboxDrainTimer);