diff --git a/src/app/(app)/__tests__/init-session-generation.test.tsx b/src/app/(app)/__tests__/init-session-generation.test.tsx new file mode 100644 index 0000000..28589ee --- /dev/null +++ b/src/app/(app)/__tests__/init-session-generation.test.tsx @@ -0,0 +1,140 @@ +/** + * Signing out while app initialization is still awaiting must retire that run: a stale + * invocation may not mark the app initialized, connect the chat hub, or restart location + * tracking that the sign-out cleanup just stopped. + * + * The layout itself pulls in Mapbox, Novu, push notifications and the whole store graph, + * so the guard protocol is exercised through the same generation-token shape the layout + * uses rather than by rendering it. + */ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +interface Deferred { + promise: Promise; + resolve: () => void; +} + +function deferred(): Deferred { + let resolve: () => void = () => undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** Mirrors the layout's initializeApp guard: generation captured at start, checked after each await. */ +function useInitGuard(gate: Deferred, effects: { connectHub: jest.Mock; startLocation: jest.Mock; markInitialized: jest.Mock }) { + const initGeneration = React.useRef(0); + const isInitializing = React.useRef(false); + + const initialize = React.useCallback(async () => { + if (isInitializing.current) return; + isInitializing.current = true; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; + + try { + await gate.promise; + if (!isCurrentRun()) return; + + effects.connectHub(); + if (!isCurrentRun()) return; + + effects.markInitialized(); + if (!isCurrentRun()) return; + + effects.startLocation(); + } finally { + if (isCurrentRun()) { + isInitializing.current = false; + } + } + }, [gate, effects]); + + const signOut = React.useCallback(() => { + initGeneration.current += 1; + isInitializing.current = false; + }, []); + + return { initialize, signOut, isInitializing }; +} + +describe('app initialization session generation', () => { + const effects = { connectHub: jest.fn(), startLocation: jest.fn(), markInitialized: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('abandons an in-flight run when the session ends mid-initialization', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + // Sign-out lands while initialization is still awaiting its first step. + act(() => { + result.current.signOut(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).not.toHaveBeenCalled(); + expect(effects.markInitialized).not.toHaveBeenCalled(); + expect(effects.startLocation).not.toHaveBeenCalled(); + }); + + it('completes normally when the session survives', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).toHaveBeenCalledTimes(1); + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); + + it('frees the in-progress guard so the next sign-in can initialize', async () => { + const first = deferred(); + const { result } = renderHook(() => useInitGuard(first, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + act(() => { + result.current.signOut(); + }); + + // The new session starts before the retired run has settled. + let second: Promise = Promise.resolve(); + act(() => { + second = result.current.initialize(); + }); + + await act(async () => { + first.resolve(); + await Promise.all([pending, second]); + }); + + // Exactly one run reached the effects: the current one. + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 0fe9ce1..9e44920 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -78,6 +78,10 @@ export default function TabLayout() { // Refs to track initialization state const hasInitialized = useRef(false); const isInitializing = useRef(false); + // Bumped on every initialization start and on sign-out. An in-flight run compares its + // captured value after each await, so a run belonging to a session that ended can no + // longer connect hubs or mark the app initialized. + const initGeneration = useRef(0); const initTimedOut = useRef(false); const hasHiddenSplash = useRef(false); const lastSignedInStatus = useRef(null); @@ -116,6 +120,8 @@ export default function TabLayout() { isInitializing.current = true; initTimedOut.current = false; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; logger.info({ message: 'Starting app initialization', context: { @@ -162,6 +168,8 @@ export default function TabLayout() { context: { platform: Platform.OS }, }); + if (!isCurrentRun()) return; + // Connect to SignalR after core initialization is complete try { await useSignalRStore.getState().connectUpdateHub(); @@ -177,6 +185,8 @@ export default function TabLayout() { // Don't fail initialization if SignalR connection fails } + if (!isCurrentRun()) return; + // Connect the realtime chat hub only when the Chat.System feature flag is on for // this department; when it is off every chat surface stays hidden. if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { @@ -199,6 +209,8 @@ export default function TabLayout() { }); } + if (!isCurrentRun()) return; + // Initialize weather alerts try { await useWeatherAlertsStore.getState().fetchSettings(); @@ -217,6 +229,8 @@ export default function TabLayout() { }); } + if (!isCurrentRun()) return; + hasInitialized.current = true; logger.info({ @@ -237,7 +251,11 @@ export default function TabLayout() { } }) .finally(() => { - isInitializing.current = false; + // Only the current run owns the guard; a superseded run clearing it would let + // two initializations overlap. + if (isCurrentRun()) { + isInitializing.current = false; + } }); await Promise.race([initPromise, initTimeout]); @@ -246,6 +264,10 @@ export default function TabLayout() { message: 'Failed to initialize app', context: { error, platform: Platform.OS }, }); + // A run whose session already ended must not clobber state a newer run (or the + // sign-out cleanup) has since established. + if (!isCurrentRun()) return; + // Reset initialization state on error so it can be retried hasInitialized.current = false; // If the init promise is still hanging, clear the guard so a retry is possible @@ -349,6 +371,12 @@ export default function TabLayout() { logger.info({ message: 'User signed out, stopping location tracking', }); + + // Retire any initialization still in flight so it cannot connect hubs or mark the + // app initialized for a session that ended, and free the guard it no longer owns + // so the next sign-in is not skipped as "already initializing". + initGeneration.current += 1; + isInitializing.current = false; } // Update last known status diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 6abe41f..3a015bf 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -8,9 +8,7 @@ import { copyToClipboard } from '@/components/chat/chat-utils'; import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; import { TypingDots } from '@/components/chat/typing-indicator'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; @@ -20,7 +18,6 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; -import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; @@ -40,8 +37,6 @@ export default function ChatbotScreen() { const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; const [text, setText] = useState(''); const [actionsMessage, setActionsMessage] = useState(null); - const [editMessage, setEditMessage] = useState(null); - const [editText, setEditText] = useState(''); useFocusEffect( useCallback(() => { @@ -147,7 +142,7 @@ export default function ChatbotScreen() { - {/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */} + {/* Restricted actions for assistant messages: copy, pin (moderator), flag. */} { - setEditMessage(m); - setEditText(m.Body ?? ''); - }} + onEdit={() => undefined} onDelete={() => undefined} onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)} onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)} onModeratorDelete={() => undefined} /> - - {/* Edit own message */} - setEditMessage(null)}> - - - - - - - {t('chat.edit_message')} - - - - - ); } diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c9effd0..9d70d37 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -43,11 +43,17 @@ const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: false, }); +// Sentry's own logger is off by default: watchdog-termination tracking rewrites the +// native scope on every RNSentry turbo-module call, so `debug` floods the Metro +// console with hundreds of "Writing tags to disk" lines a second. Flip to `__DEV__` +// temporarily when diagnosing Sentry itself. +const SENTRY_DEBUG = false; + // Only initialize Sentry if a DSN is provided if (Env.SENTRY_DSN) { Sentry.init({ dsn: Env.SENTRY_DSN, - debug: __DEV__, // Only debug in development, not production + debug: SENTRY_DEBUG, tracesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact profilesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact sendDefaultPii: false, diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 3618204..353b1c4 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -7,7 +7,7 @@ import { Platform } from 'react-native'; import { getPresence, uploadAttachment } from '@/api/chat/chat'; import { AckBanner } from '@/components/chat/ack-banner'; -import { copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils'; +import { buildGifMetadata, buildLocationMetadata, copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils'; import { GifPickerSheet } from '@/components/chat/gif-picker-sheet'; import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; @@ -152,7 +152,7 @@ export default function ChannelConversationScreen() { const handleSendGif = useCallback( (gif: GifResultData) => { if (!channelId) return; - const metadata = JSON.stringify({ GifUrl: gif.GifUrl, PreviewUrl: gif.PreviewUrl, Width: gif.Width, Height: gif.Height, Title: gif.Title }); + const metadata = buildGifMetadata(gif); void useChatStore.getState().sendMessage({ channelId, body: gif.Title ?? 'GIF', messageType: ChatMessageType.Gif, metadataJson: metadata }); }, [channelId] @@ -161,7 +161,7 @@ export default function ChannelConversationScreen() { const handleSendLocation = useCallback( (latitude: number, longitude: number, urgent: boolean) => { if (!channelId) return; - const metadata = JSON.stringify({ Latitude: latitude, Longitude: longitude }); + const metadata = buildLocationMetadata(latitude, longitude); void useChatStore.getState().sendMessage({ channelId, body: t('chat.shared_location'), @@ -343,7 +343,7 @@ export default function ChannelConversationScreen() { handleToggleReaction( m, emoji, - m.Reactions.some((r) => r.Emoji === emoji && r.UserId === currentUserId) + (m.Reactions ?? []).some((r) => r.Emoji === emoji && r.UserId === currentUserId) ) } onReply={openThread} diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx index 09a2181..eb26c01 100644 --- a/src/app/chat/thread/[messageId].tsx +++ b/src/app/chat/thread/[messageId].tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; import { getThread } from '@/api/chat/chat'; +import { buildLocationMetadata } from '@/components/chat/chat-utils'; import { MessageBubble } from '@/components/chat/message-bubble'; import { MessageComposer } from '@/components/chat/message-composer'; import { Box } from '@/components/ui/box'; @@ -63,10 +64,6 @@ export default function ThreadScreen() { [channelId, messageId] ); - const handleSendGif = useCallback(() => { - // GIFs in threads are sent as text-less messages via the composer's gif flow; kept minimal here. - }, []); - const handleSendLocation = useCallback( (latitude: number, longitude: number, urgent: boolean) => { if (!channelId || !messageId) return; @@ -74,7 +71,7 @@ export default function ThreadScreen() { channelId, body: t('chat.shared_location'), messageType: ChatMessageType.Location, - metadataJson: JSON.stringify({ Latitude: latitude, Longitude: longitude }), + metadataJson: buildLocationMetadata(latitude, longitude), threadRootMessageId: messageId, priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal, }); @@ -131,7 +128,9 @@ export default function ThreadScreen() { item.ChatMessageId} renderItem={renderItem} contentContainerStyle={{ paddingVertical: 8 }} /> - undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} /> + {/* Threads carry text and location only; omitting the image/GIF callbacks keeps + those actions out of the composer instead of showing dead buttons. */} + undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} /> ); diff --git a/src/components/chat/__tests__/message-composer.test.tsx b/src/components/chat/__tests__/message-composer.test.tsx new file mode 100644 index 0000000..4c98391 --- /dev/null +++ b/src/components/chat/__tests__/message-composer.test.tsx @@ -0,0 +1,93 @@ +/** + * The composer toolbar must only offer actions the host surface can actually perform. + * Thread replies send text and location only; when they still rendered the image button + * the picker opened and the chosen photo was silently dropped. + */ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('expo-image-picker', () => ({ + requestMediaLibraryPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }), + launchImageLibraryAsync: jest.fn().mockResolvedValue({ canceled: true, assets: [] }), +})); + +jest.mock('expo-location', () => ({ + requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({ granted: true }), + getCurrentPositionAsync: jest.fn().mockResolvedValue({ coords: { latitude: 0, longitude: 0 } }), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: { getState: () => ({ showToast: jest.fn() }) }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// The shared lucide mock only exports the icons other suites use; this composer pulls in +// several it does not, which would otherwise render as undefined elements. +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref })); + return new Proxy({}, { get: () => icon }); +}); + +import { MessageComposer } from '../message-composer'; + +const baseProps = { + onSendText: jest.fn(), + onSendLocation: jest.fn(), + onTyping: jest.fn(), +}; + +describe('MessageComposer attachment actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('offers image and GIF when both callbacks are provided', () => { + render(); + + expect(screen.queryByLabelText('chat.add_image')).not.toBeNull(); + expect(screen.queryByLabelText('chat.add_gif')).not.toBeNull(); + expect(screen.queryByLabelText('chat.emoji')).not.toBeNull(); + }); + + it('hides both when neither callback is provided, as thread replies do', () => { + render(); + + expect(screen.queryByLabelText('chat.add_image')).toBeNull(); + expect(screen.queryByLabelText('chat.add_gif')).toBeNull(); + // The actions a thread can still perform stay available. + expect(screen.queryByLabelText('chat.emoji')).not.toBeNull(); + expect(screen.queryByLabelText('chat.share_location')).not.toBeNull(); + }); + + it('hides only the GIF action when images are supported but GIFs are not', () => { + render(); + + expect(screen.queryByLabelText('chat.add_image')).not.toBeNull(); + expect(screen.queryByLabelText('chat.add_gif')).toBeNull(); + }); + + it('hides only the image action when GIFs are supported but images are not', () => { + render(); + + expect(screen.queryByLabelText('chat.add_image')).toBeNull(); + expect(screen.queryByLabelText('chat.add_gif')).not.toBeNull(); + }); + + it('invokes the GIF callback when the action is used', () => { + const onOpenGif = jest.fn(); + render(); + + fireEvent.press(screen.getByLabelText('chat.add_gif')); + + expect(onOpenGif).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/chat/chat-utils.ts b/src/components/chat/chat-utils.ts index ac54751..3d0476b 100644 --- a/src/components/chat/chat-utils.ts +++ b/src/components/chat/chat-utils.ts @@ -64,12 +64,55 @@ export function parseMetadata(metadataJson?: string | null): T | null { } } +/** + * MetadataJson wire contract, shared with the web client: a nested, camelCase envelope + * — `{ location: { latitude, longitude, label } }` and `{ gif: { url, previewUrl, width, + * height } }`. Earlier mobile builds wrote a flat PascalCase object that the web client + * cannot read (it falls back to rendering the message body), so the builders below emit + * only the nested form while the parsers still accept the flat one for history already + * stored on the server. + */ +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export function buildLocationMetadata(latitude: number, longitude: number, label?: string): string { + return JSON.stringify({ location: { latitude, longitude, label } }); +} + +export function buildGifMetadata(gif: { GifUrl: string; PreviewUrl?: string; Width?: number; Height?: number }): string { + return JSON.stringify({ gif: { url: gif.GifUrl, previewUrl: gif.PreviewUrl, width: gif.Width, height: gif.Height } }); +} + export function parseLocationMetadata(metadataJson?: string | null): ChatLocationMetadata | null { - return parseMetadata(metadataJson); + const raw = parseMetadata>(metadataJson); + if (!raw) return null; + const nested = (raw.location ?? raw.Location) as Record | undefined; + const source = nested ?? raw; + const latitude = readNumber(source.latitude ?? source.Latitude); + const longitude = readNumber(source.longitude ?? source.Longitude); + if (latitude === undefined || longitude === undefined) return null; + return { Latitude: latitude, Longitude: longitude, Label: readString(source.label ?? source.Label) }; } export function parseGifMetadata(metadataJson?: string | null): ChatGifMetadata | null { - return parseMetadata(metadataJson); + const raw = parseMetadata>(metadataJson); + if (!raw) return null; + const nested = (raw.gif ?? raw.Gif) as Record | undefined; + const source = nested ?? raw; + const url = readString(source.url ?? source.Url ?? source.gifUrl ?? source.GifUrl); + if (!url) return null; + return { + GifUrl: url, + PreviewUrl: readString(source.previewUrl ?? source.PreviewUrl), + Width: readNumber(source.width ?? source.Width), + Height: readNumber(source.height ?? source.Height), + Title: readString(source.title ?? source.Title), + }; } export function parseImageMetadata(metadataJson?: string | null): ChatImageMetadata | null { diff --git a/src/components/chat/message-actions-sheet.tsx b/src/components/chat/message-actions-sheet.tsx index 1bc66c2..c6ee317 100644 --- a/src/components/chat/message-actions-sheet.tsx +++ b/src/components/chat/message-actions-sheet.tsx @@ -16,7 +16,7 @@ interface MessageActionsSheetProps { onClose: () => void; isOwn: boolean; isModerator: boolean; - /** Assistant conversations: no reactions, threads or deletes — copy, edit own, pin and flag stay. */ + /** Assistant conversations: no reactions, threads, deletes or edits — copy, pin and flag stay. */ assistant?: boolean; onReact: (message: ChatMessageResultData, emoji: string) => void; onReply: (message: ChatMessageResultData) => void; @@ -118,7 +118,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : null} - {isOwn && isText && !isDeleted ? ( + {isOwn && isText && !isDeleted && !assistant ? ( { onEdit(message); diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index fc92b09..1bdf056 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Linking } from 'react-native'; import { getChatAttachmentImageSource } from '@/api/chat/chat'; -import { Avatar, AvatarFallbackText, AvatarImage } from '@/components/ui/avatar'; +import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Box } from '@/components/ui/box'; import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; @@ -30,9 +30,11 @@ interface MessageBubbleProps { export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { const { t } = useTranslation(); + // Realtime payloads omit empty collections; the store normalizes them, but messages + // persisted before that normalization existed can still come back without them. const groupedReactions = useMemo(() => { const map = new Map(); - for (const reaction of message.Reactions) { + for (const reaction of message.Reactions ?? []) { const current = map.get(reaction.Emoji) ?? { count: 0, mine: false }; current.count += 1; if (reaction.UserId && reaction.UserId === currentUserId) current.mine = true; @@ -65,7 +67,7 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon } if (message.MessageType === ChatMessageType.Image) { - const attachment = message.Attachments[0]; + const attachment = (message.Attachments ?? [])[0]; const uri = message._localAttachmentUri ?? (attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId).uri : undefined); const source = attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId) : uri ? { uri } : undefined; if (!source) return {message.Body}; @@ -121,14 +123,9 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon return ( - {!isOwn && showSender ? ( - - {message.SenderDisplayName ?? '?'} - {message.SenderUserId ? : null} - - ) : !isOwn ? ( - - ) : null} + {/* No initials fallback: the avatar endpoint always answers with a silhouette + placeholder rather than a 404, so initials would never be visible anyway. */} + {!isOwn && showSender ? {message.SenderUserId ? : null} : !isOwn ? : null} {!isOwn && showSender && message.SenderDisplayName ? {message.SenderDisplayName} : null} diff --git a/src/components/chat/message-composer.tsx b/src/components/chat/message-composer.tsx index 45a4f14..51ad4c8 100644 --- a/src/components/chat/message-composer.tsx +++ b/src/components/chat/message-composer.tsx @@ -19,9 +19,12 @@ const TYPING_IDLE_MS = 3000; interface MessageComposerProps { onSendText: (body: string, urgent: boolean) => void; - onSendImage: (uri: string, urgent: boolean, mimeType?: string) => void; + /** Omit to hide the image action; a surface that cannot send images (thread replies) + * would otherwise open the picker and silently discard the chosen photo. */ + onSendImage?: (uri: string, urgent: boolean, mimeType?: string) => void; onSendLocation: (latitude: number, longitude: number, urgent: boolean) => void; - onOpenGif: () => void; + /** Omit to hide the GIF action on surfaces that cannot send GIFs. */ + onOpenGif?: () => void; onTyping: (isTyping: boolean) => void; disabled?: boolean; placeholder?: string; @@ -81,6 +84,7 @@ export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpe }, [text, urgent, onSendText, stopTyping]); const handlePickImage = useCallback(async () => { + if (!onSendImage) return; try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { @@ -125,12 +129,16 @@ export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpe - - - - - - + {onSendImage ? ( + + + + ) : null} + {onOpenGif ? ( + + + + ) : null} diff --git a/src/components/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx index 100ef30..7365d6d 100644 --- a/src/components/chat/new-conversation-sheet.tsx +++ b/src/components/chat/new-conversation-sheet.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { createAdHocChannel, createDirectMessage } from '@/api/chat/chat'; import { getRecipients } from '@/api/messaging/messages'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; -import { Avatar, AvatarFallbackText, AvatarImage } from '@/components/ui/avatar'; +import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; @@ -172,7 +172,6 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo - {recipient.Name} diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 385d800..8ffa69d 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -24,6 +24,9 @@ export interface SignalRMessage { data: unknown; } +/** Hub events can carry multiple positional arguments; listeners receive all of them. */ +export type SignalREventListener = (...data: unknown[]) => void; + export enum HubConnectingState { IDLE = 'idle', RECONNECTING = 'reconnecting', @@ -35,7 +38,7 @@ export enum HubConnectingState { */ interface HubMethodHandler { method: string; - handler: (data: unknown) => void; + handler: SignalREventListener; } /** @@ -44,6 +47,14 @@ interface HubMethodHandler { * and proper cleanup. */ class SignalRService { + /** + * Per-hub transport lifecycle signals. Group membership is scoped to a connection id, so + * a subscriber that joined server-side groups has to re-announce itself after every + * reconnect — these are how it learns that happened. + */ + public static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected'; + public static readonly HUB_RECONNECTED_EVENT = '__hubReconnected'; + private connections: Map = new Map(); private reconnectAttempts: Map = new Map(); private hubConfigs: Map = new Map(); @@ -58,7 +69,7 @@ class SignalRService { private hubMethodHandlers: Map = new Map(); // Event emitter with proper cleanup tracking - private eventListeners: Map void>> = new Map(); + private eventListeners: Map> = new Map(); // Web platform visibility tracking private isPageVisible: boolean = true; @@ -393,6 +404,7 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); @@ -409,6 +421,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // A reconnect issues a new connection id, so any server-side group this connection + // belonged to is gone. Subscribers must re-announce themselves. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Initialize handlers array for this hub @@ -421,12 +436,12 @@ class SignalRService { context: { method }, }); - const handler = (data: unknown) => { + const handler = (...args: unknown[]) => { logger.info({ message: `Received ${method} message from hub: ${config.name}`, - context: { method, data }, + context: { method, args }, }); - this.handleMessage(config.name, method, data); + this.handleMessage(config.name, method, args); }; connection.on(method, handler); @@ -575,6 +590,7 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); @@ -591,6 +607,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // A reconnect issues a new connection id, so any server-side group this connection + // belonged to is gone. Subscribers must re-announce themselves. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Initialize handlers array for this hub @@ -603,12 +622,12 @@ class SignalRService { context: { method }, }); - const handler = (data: unknown) => { + const handler = (...args: unknown[]) => { logger.info({ message: `Received ${method} message from hub: ${config.name}`, - context: { method, data }, + context: { method, args }, }); - this.handleMessage(config.name, method, data); + this.handleMessage(config.name, method, args); }; connection.on(method, handler); @@ -803,13 +822,15 @@ class SignalRService { } } - private handleMessage(hubName: string, method: string, data: unknown): void { + private handleMessage(hubName: string, method: string, args: unknown[]): void { logger.debug({ message: `Received message from hub: ${hubName}`, - context: { method, data }, + context: { method, args }, }); - // Emit event for subscribers using the method name as the event name - this.emit(method, data); + // Emit event for subscribers using the method name as the event name. Hub methods + // can send more than one argument (chatPresenceChanged sends `userId, isOnline`), + // so forward every argument to the listeners. + this.emit(method, ...args); } public async disconnectFromHub(hubName: string): Promise { @@ -950,14 +971,14 @@ class SignalRService { } // Event emitter methods - note: eventListeners is declared in the class properties above - public on(event: string, callback: (data: unknown) => void): void { + public on(event: string, callback: SignalREventListener): void { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event)?.add(callback); } - public off(event: string, callback: (data: unknown) => void): void { + public off(event: string, callback: SignalREventListener): void { this.eventListeners.get(event)?.delete(callback); } @@ -975,10 +996,16 @@ class SignalRService { this.eventListeners.clear(); } - private emit(event: string, data: unknown): void { + /** Raises a lifecycle signal both unqualified and scoped to the hub that produced it. */ + private emitHubLifecycle(event: string, hubName: string): void { + this.emit(event, hubName); + this.emit(`${event}:${hubName}`, hubName); + } + + private emit(event: string, ...data: unknown[]): void { this.eventListeners.get(event)?.forEach((callback) => { try { - callback(data); + callback(...data); } catch (error) { logger.error({ message: `Error in event listener for event: ${event}`, diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts new file mode 100644 index 0000000..b7a8bc3 --- /dev/null +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -0,0 +1,172 @@ +/** + * SignalR binds hub arguments positionally and rejects an invocation that supplies + * fewer arguments than the hub method declares — C# default values do not make a + * parameter optional on the wire. These tests pin the argument counts against the + * ChatHub signatures so a short invoke can never silently strand the client outside + * its channel groups again: + * + * JoinChannel(string channelId, int? asUnitId) + * Typing(string channelId, string displayName, bool isTyping, int? asUnitId) + * MarkRead(string channelId, long seq, int? asUnitId) + */ +const mockInvoke = jest.fn().mockResolvedValue(undefined); + +jest.mock('@/services/signalr.service', () => ({ + signalRService: { invoke: mockInvoke }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { CHAT_HUB_NAME: 'chatHub' }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() }, +})); + +jest.mock('@/lib/i18n/utils', () => ({ translate: (key: string) => key })); + +jest.mock('@/lib/storage', () => ({ zustandStorage: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn() } })); + +jest.mock('@/api/chat/chat', () => ({ + getChannels: jest.fn().mockResolvedValue({ Data: [] }), + getMessages: jest.fn().mockResolvedValue({ Data: [] }), + getMembers: jest.fn().mockResolvedValue({ Data: [] }), + getMyPendingAcks: jest.fn().mockResolvedValue({ Data: [] }), + markRead: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@/api/chat/chatbot', () => ({ + getChatbotChannel: jest.fn(), + sendChatbotMessage: jest.fn(), + newChatbotSession: jest.fn(), +})); + +jest.mock('@/stores/auth/store', () => ({ + __esModule: true, + default: { getState: () => ({ userId: 'user-1', profile: { name: 'Test User' } }) }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: { getState: () => ({ showToast: jest.fn() }) }, +})); + +// Loaded lazily so the mock factories above run after their `mock*` consts exist. +type ChatStoreApi = typeof import('../store').useChatStore; +let useChatStore: ChatStoreApi; + +beforeAll(() => { + useChatStore = require('../store').useChatStore as ChatStoreApi; +}); + +describe('chat hub invocations', () => { + beforeEach(() => { + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + useChatStore.setState({ messagesByChannel: {}, channels: [] }); + }); + + it('sends both JoinChannel arguments', async () => { + await useChatStore.getState().joinChannel('channel-1'); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', null); + }); + + it('sends all four Typing arguments in hub order', () => { + useChatStore.getState().sendTyping('channel-1', true); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'Typing', 'channel-1', 'Test User', true, null); + }); + + it('sends all three MarkRead arguments', async () => { + useChatStore.setState({ + messagesByChannel: { + 'channel-1': [ + { + ChatMessageId: 'm1', + ChatChannelId: 'channel-1', + MessageSeq: 42, + SenderParticipantType: 0, + SenderUserId: 'user-2', + SenderDisplayName: 'Other', + Body: 'hi', + MessageType: 0, + Priority: 0, + ThreadRootMessageId: null, + ThreadReplyCount: 0, + AlsoSendToChannel: false, + MetadataJson: null, + ClientMessageId: 'c1', + SentOn: new Date(0).toISOString(), + Reactions: [], + Attachments: [], + }, + ], + }, + } as unknown as Parameters[0]); + + await useChatStore.getState().markChannelRead('channel-1'); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'MarkRead', 'channel-1', 42, null); + }); +}); + +describe('incoming message normalization', () => { + beforeEach(() => { + useChatStore.setState({ messagesByChannel: {}, channels: [] }); + }); + + it('fills in collections the hub payload omits', () => { + // The hub sends the message DTO as a JSON string and drops empty collections. + useChatStore.getState().handleMessageReceived(JSON.stringify({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString() })); + + const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0]; + expect(stored?.Reactions).toEqual([]); + expect(stored?.Attachments).toEqual([]); + }); + + it('keeps existing reactions when a later payload omits them', () => { + useChatStore.getState().handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] }); + useChatStore.getState().handleMessageEdited({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi (edited)', SentOn: new Date(0).toISOString() }); + + const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0]; + expect(stored?.Body).toBe('hi (edited)'); + expect(stored?.Reactions).toHaveLength(1); + }); +}); + +describe('chat presence events', () => { + beforeEach(() => { + useChatStore.setState({ presence: new Set() }); + }); + + it('accepts the hub positional (userId, isOnline) form', () => { + useChatStore.getState().handlePresenceChanged('user-2', true); + expect(useChatStore.getState().presence.has('user-2')).toBe(true); + + useChatStore.getState().handlePresenceChanged('user-2', false); + expect(useChatStore.getState().presence.has('user-2')).toBe(false); + }); + + it('still accepts an object payload', () => { + useChatStore.getState().handlePresenceChanged({ UserId: 'user-3', IsOnline: true }); + expect(useChatStore.getState().presence.has('user-3')).toBe(true); + }); +}); + +describe('chat typing events', () => { + beforeEach(() => { + useChatStore.setState({ typingByChannel: {} }); + }); + + it('reads the hub payload ChannelId field', () => { + useChatStore.getState().handleTyping({ ChannelId: 'channel-1', UserId: 'user-2', DisplayName: 'Other', IsTyping: true }); + + expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.displayName).toBe('Other'); + }); + + it('reads a camelCase hub payload', () => { + useChatStore.getState().handleTyping({ channelId: 'channel-1', userId: 'user-2', displayName: 'Other', isTyping: true }); + + expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.userId).toBe('user-2'); + }); +}); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 0631815..aec4897 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -119,7 +119,7 @@ interface ChatState { handleChatbotMessageReceived: (raw: unknown) => void; handleChatbotTyping: (raw: unknown) => void; handleTyping: (raw: unknown) => void; - handlePresenceChanged: (raw: unknown) => void; + handlePresenceChanged: (raw: unknown, isOnlineArg?: unknown) => void; handleChatConnected: () => void; reset: () => void; @@ -145,6 +145,11 @@ function currentUserId(): string | null { return useAuthStore.getState().userId; } +/** Name broadcast with typing signals; the hub echoes it to the other participants. */ +function currentDisplayName(): string | null { + return useAuthStore.getState().profile?.name ?? null; +} + function parseEventData(raw: unknown): T | null { if (raw == null) return null; if (typeof raw === 'string') { @@ -168,6 +173,18 @@ function compareMessages(a: ChatMessageResultData, b: ChatMessageResultData): nu return new Date(a.SentOn).getTime() - new Date(b.SentOn).getTime(); } +/** The realtime payloads omit empty collections even though the DTO types them as + * required, so every stored message is normalized on the way in — the UI iterates + * Reactions/Attachments directly. An existing value always wins over a missing one + * so a partial hub update can never drop reactions already on screen. */ +function withCollections(incoming: ChatMessageResultData, existing?: ChatMessageResultData): ChatMessageResultData { + return { + ...incoming, + Reactions: incoming.Reactions ?? existing?.Reactions ?? [], + Attachments: incoming.Attachments ?? existing?.Attachments ?? [], + }; +} + /** Insert or replace a message in an ascending-by-sequence list, de-duplicated * by ChatMessageId and ClientMessageId (so optimistic sends reconcile). */ function upsertMessage(list: ChatMessageResultData[], incoming: ChatMessageResultData): ChatMessageResultData[] { @@ -175,9 +192,9 @@ function upsertMessage(list: ChatMessageResultData[], incoming: ChatMessageResul const idx = next.findIndex((m) => m.ChatMessageId === incoming.ChatMessageId || (!!incoming.ClientMessageId && !!m.ClientMessageId && m.ClientMessageId === incoming.ClientMessageId)); if (idx >= 0) { const existing = next[idx]; - next[idx] = { ...existing, ...incoming }; + next[idx] = withCollections({ ...existing, ...incoming }, existing); } else { - next.push(incoming); + next.push(withCollections(incoming)); } next.sort(compareMessages); return next; @@ -529,7 +546,8 @@ export const useChatStore = create()( channels: s.channels.map((c) => (c.ChatChannelId === channelId ? { ...c, UnreadCount: 0, MyLastReadSeq: seq } : c)), })); - void safeInvoke('MarkRead', channelId, seq); + // Hub signature: MarkRead(channelId, seq, asUnitId). + void safeInvoke('MarkRead', channelId, seq, null); try { await chatApi.markRead(channelId, { Seq: seq }); } catch (error) { @@ -644,7 +662,11 @@ export const useChatStore = create()( // Realtime send helpers // ------------------------------------------------------------------ joinChannel: async (channelId: string) => { - await safeInvoke('JoinChannel', channelId); + // Hub signature: JoinChannel(channelId, asUnitId). SignalR binds hub arguments + // positionally and rejects an invocation that supplies fewer than the method + // declares, so omitting the optional argument left the connection outside the + // channel group and the channel permanently silent. + await safeInvoke('JoinChannel', channelId, null); }, sendTyping: (channelId: string, isTyping: boolean) => { @@ -656,7 +678,8 @@ export const useChatStore = create()( } else { lastTypingSentAt.delete(channelId); } - void safeInvoke('Typing', channelId, isTyping); + // Hub signature: Typing(channelId, displayName, isTyping, asUnitId). + void safeInvoke('Typing', channelId, currentDisplayName(), isTyping, null); }, // ------------------------------------------------------------------ @@ -761,8 +784,10 @@ export const useChatStore = create()( }, handleTyping: (raw: unknown) => { - const obj = (typeof raw === 'object' && raw !== null ? (raw as Record) : {}) as Record; - const channelId = (obj.ChatChannelId ?? obj.chatChannelId ?? obj.ChannelId) as string | undefined; + // The hub payload uses ChannelId (not ChatChannelId) and its casing depends on the + // server's JSON naming policy, so accept both spellings of every field. + const obj = (parseEventData>(raw) ?? {}) as Record; + const channelId = (obj.ChatChannelId ?? obj.chatChannelId ?? obj.ChannelId ?? obj.channelId) as string | undefined; const userId = (obj.UserId ?? obj.userId) as string | undefined; const displayName = (obj.DisplayName ?? obj.displayName) as string | undefined; const isTyping = (obj.IsTyping ?? obj.isTyping) as boolean | undefined; @@ -776,10 +801,12 @@ export const useChatStore = create()( addTyping(set, channelId, { userId, displayName, expiresAt: Date.now() + TYPING_EXPIRY_MS }); }, - handlePresenceChanged: (raw: unknown) => { + handlePresenceChanged: (raw: unknown, isOnlineArg?: unknown) => { + // The hub sends `chatPresenceChanged` as two positional args (userId, isOnline); + // keep the object form working in case a future producer sends a DTO. const obj = (typeof raw === 'object' && raw !== null ? (raw as Record) : {}) as Record; - const userId = (obj.UserId ?? obj.userId) as string | undefined; - const isOnline = (obj.IsOnline ?? obj.isOnline) as boolean | undefined; + const userId = typeof raw === 'string' ? raw : ((obj.UserId ?? obj.userId) as string | undefined); + const isOnline = typeof raw === 'string' ? Boolean(isOnlineArg) : ((obj.IsOnline ?? obj.isOnline) as boolean | undefined); if (!userId) return; set((s) => { const presence = new Set(s.presence); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index e75530c..4f5ce2e 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import { useAuthStore } from '@/lib'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; -import { signalRService } from '@/services/signalr.service'; +import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; @@ -30,9 +30,30 @@ const CHAT_HUB_METHODS = [ ]; // Track registered chat handlers for cleanup and the heartbeat timer. -const chatHubHandlers: Record void) | null> = {}; +// Hub methods can send several positional arguments, so handlers are variadic. +const chatHubHandlers: Record void) | null> = {}; let chatHeartbeatTimer: ReturnType | null = null; const CHAT_HEARTBEAT_INTERVAL_MS = 45000; +const CHAT_ARM_RETRY_MS = 5000; +const CHAT_ARM_MAX_ATTEMPTS = 3; +// The hub replays a full resync on arm; collapse the duplicate that arrives when the +// server echoes its own onChatConnected right after ours. Scoped to a single connection — +// a disconnect clears the marker so the next one resyncs immediately. +const CHAT_RESYNC_DEBOUNCE_MS = 2000; + +let chatArmRetryTimer: ReturnType | null = null; +let chatArmAttempts = 0; +// The arm in flight, shared by the reconnect handler and the connectChatHub fallback so a +// fresh connection announces itself exactly once. +let chatArmOperation: Promise | null = null; +let lastChatResyncAt = 0; + +function stopChatArmRetry(): void { + if (chatArmRetryTimer) { + clearTimeout(chatArmRetryTimer); + chatArmRetryTimer = null; + } +} function unregisterChatHubHandlers(): void { Object.keys(chatHubHandlers).forEach((event) => { @@ -51,6 +72,77 @@ function stopChatHeartbeat(): void { } } +function resyncChat(): void { + const now = Date.now(); + if (now - lastChatResyncAt < CHAT_RESYNC_DEBOUNCE_MS) return; + lastChatResyncAt = now; + useChatStore.getState().handleChatConnected(); +} + +/** + * Announce this connection to the chat hub and restart the heartbeat. + * + * The hub only places a connection into its channel groups in response to `Connect`, and + * every reconnect issues a fresh connection id. Without re-arming, the websocket stays + * open but the client receives nothing. + */ +async function runChatArm(): Promise { + stopChatArmRetry(); + + try { + await signalRService.invoke(Env.CHAT_HUB_NAME, 'Connect'); + } catch (error) { + chatArmAttempts += 1; + logger.warn({ + message: 'Failed to announce presence to chat hub', + context: { error, attempt: chatArmAttempts, maxAttempts: CHAT_ARM_MAX_ATTEMPTS }, + }); + if (chatArmAttempts < CHAT_ARM_MAX_ATTEMPTS) { + chatArmRetryTimer = setTimeout(() => { + void armChatSession(); + }, CHAT_ARM_RETRY_MS); + } + throw error; + } + + chatArmAttempts = 0; + + stopChatHeartbeat(); + chatHeartbeatTimer = setInterval(() => { + signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => { + // Heartbeat is best-effort; ignore transient failures. + }); + }, CHAT_HEARTBEAT_INTERVAL_MS); + + resyncChat(); +} + +/** + * Serializes arming per connection: the reconnect handler and connectChatHub both reach + * for an arm on a fresh socket, and the reconnect one parks on the connection lock, so + * without sharing the operation the second issues a duplicate `Connect` and the two runs + * race each other's retry timer. + * + * `resetAttempts` accompanies a new connection id, which always deserves a full budget. + */ +function armChatSession(options?: { resetAttempts?: boolean }): Promise { + if (options?.resetAttempts) { + chatArmAttempts = 0; + } + + if (chatArmOperation) { + return chatArmOperation; + } + + const operation = runChatArm().finally(() => { + if (chatArmOperation === operation) { + chatArmOperation = null; + } + }); + chatArmOperation = operation; + return operation; +} + export type SignalREventType = | 'personnelStatusUpdated' | 'personnelStaffingUpdated' @@ -653,7 +745,7 @@ export const useSignalRStore = create((set, get) => ({ }); const chat = useChatStore.getState(); - const handlerMap: Record void> = { + const handlerMap: Record void> = { chatMessageReceived: chat.handleMessageReceived, chatMessageEdited: chat.handleMessageEdited, chatMessageDeleted: chat.handleMessageDeleted, @@ -671,7 +763,7 @@ export const useSignalRStore = create((set, get) => ({ }; Object.entries(handlerMap).forEach(([event, handler]) => { - const wrapped = (data: unknown) => handler(data); + const wrapped = (...args: unknown[]) => handler(...args); chatHubHandlers[event] = wrapped; signalRService.on(event, wrapped); }); @@ -679,21 +771,41 @@ export const useSignalRStore = create((set, get) => ({ const onChatConnected = () => { logger.info({ message: 'Connected to chat SignalR hub' }); set({ isChatHubConnected: true, error: null }); - useChatStore.getState().handleChatConnected(); + resyncChat(); }; chatHubHandlers.onChatConnected = onChatConnected; signalRService.on('onChatConnected', onChatConnected); - // Announce chat presence to the hub, then begin the periodic heartbeat. - await signalRService.invoke(Env.CHAT_HUB_NAME, 'Connect'); - set({ isChatHubConnected: true }); + // A dropped transport reconnects with a fresh connection id that belongs to no + // channel groups, so it has to announce itself again or the socket stays open and + // silent. + const chatReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHAT_HUB_NAME}`; + const chatDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHAT_HUB_NAME}`; - stopChatHeartbeat(); - chatHeartbeatTimer = setInterval(() => { - signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => { - // Heartbeat is best-effort; ignore transient failures. + const onChatReconnected = () => { + void armChatSession({ resetAttempts: true }).catch(() => { + // runChatArm already logged and scheduled its retry. }); - }, CHAT_HEARTBEAT_INTERVAL_MS); + }; + chatHubHandlers[chatReconnected] = onChatReconnected; + signalRService.on(chatReconnected, onChatReconnected); + + const onChatDisconnected = () => { + stopChatHeartbeat(); + stopChatArmRetry(); + // The debounce only guards duplicates within one connection; carrying the marker + // across the gap would swallow the resync that backfills the outage. + lastChatResyncAt = 0; + // Clearing the flag is what lets connectChatHub repair the session later; while it + // stayed true the hub could never be re-announced. + set({ isChatHubConnected: false }); + }; + chatHubHandlers[chatDisconnected] = onChatDisconnected; + signalRService.on(chatDisconnected, onChatDisconnected); + + // Announce chat presence to the hub, then begin the periodic heartbeat. + await armChatSession({ resetAttempts: true }); + set({ isChatHubConnected: true }); logger.info({ message: 'Chat hub handlers registered successfully' }); } catch (error) { @@ -705,6 +817,9 @@ export const useSignalRStore = create((set, get) => ({ disconnectChatHub: async () => { try { stopChatHeartbeat(); + stopChatArmRetry(); + chatArmAttempts = 0; + lastChatResyncAt = 0; unregisterChatHubHandlers(); await signalRService.disconnectFromHub(Env.CHAT_HUB_NAME); set({ isChatHubConnected: false });