diff --git a/src/components/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx index 7365d6d..72dfa49 100644 --- a/src/components/chat/new-conversation-sheet.tsx +++ b/src/components/chat/new-conversation-sheet.tsx @@ -1,4 +1,4 @@ -import { Check, Search, Users } from 'lucide-react-native'; +import { Check, Search, Truck, Users } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,8 +34,15 @@ function recipientUserId(recipient: RecipientsResultData): string { } function isPersonRecipient(recipient: RecipientsResultData): boolean { + // Recipients with an empty Type are the server's pseudo-entries + // ({ Id: "0", Name: "Everyone" } / { Id: "-1", Name: "Nobody" }) — never DM targets. const type = (recipient.Type ?? '').toLowerCase(); - return type === 'personnel' || type === 'person' || type === 'user' || type === 'p' || type === ''; + return type === 'personnel' || type === 'person' || type === 'user' || type === 'p'; +} + +function isUnitRecipient(recipient: RecipientsResultData): boolean { + const type = (recipient.Type ?? '').toLowerCase(); + return type === 'unit' || type === 'units' || type === 'u'; } export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewConversationSheetProps) { @@ -56,10 +63,13 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo setQuery(''); setLoadError(false); setLoading(true); - getRecipients(true, false) + // DM mode also offers units (Dispatch can open a 1:1 with a unit); + // group membership only supports users, so group mode stays people-only. + const includeUnits = mode === 'dm'; + getRecipients(true, includeUnits) .then((result) => { if (cancelled) return; - setRecipients((result.Data ?? []).filter(isPersonRecipient)); + setRecipients((result.Data ?? []).filter((r) => isPersonRecipient(r) || (includeUnits && isUnitRecipient(r)))); }) .catch((error) => { if (cancelled) return; @@ -73,7 +83,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo return () => { cancelled = true; }; - }, [isOpen]); + }, [isOpen, mode]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -94,7 +104,8 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo async (recipient: RecipientsResultData) => { setSubmitting(true); try { - const response = await createDirectMessage({ TargetUserId: recipientUserId(recipient) }); + const targetId = recipientUserId(recipient); + const response = await createDirectMessage(isUnitRecipient(recipient) ? { TargetUnitId: parseInt(targetId, 10) } : { TargetUserId: targetId }); if (response.Data?.ChatChannelId) { onCreated(response.Data.ChatChannelId); onClose(); @@ -110,9 +121,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. const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) }); if (response.Data?.ChatChannelId) { onCreated(response.Data.ChatChannelId); @@ -139,7 +151,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo {mode === 'group' ? ( - + ) : null} @@ -167,16 +179,28 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo {filtered.map((recipient) => { const userId = recipientUserId(recipient); const isSelected = selected.has(userId); + const isUnit = isUnitRecipient(recipient); return ( (mode === 'dm' ? startDirectMessage(recipient) : toggle(userId))} disabled={submitting}> - - - + {isUnit ? ( +
+ +
+ ) : ( + + + + )} {recipient.Name} + {isUnit ? ( + + {t('chat.unit')} + + ) : null}
{mode === 'group' && isSelected ? ( @@ -191,7 +215,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo )} {mode === 'group' ? ( - diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index b7a8bc3..d6e3673 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); @@ -71,6 +72,15 @@ describe('chat hub invocations', () => { expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', null); }); + it('sends both SetActiveChannel arguments, with null clearing the marker', () => { + useChatStore.getState().setActiveChannel('channel-1'); + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', null); + + mockInvoke.mockClear(); + useChatStore.getState().setActiveChannel(null); + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, null); + }); + it('sends all four Typing arguments in hub order', () => { useChatStore.getState().sendTyping('channel-1', true); @@ -110,6 +120,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', null); + }); + + 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, null); + }); + + 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 ee38a2e..67f3cb0 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -233,6 +233,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, 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) => ({ @@ -285,6 +302,9 @@ export const useChatStore = create()( setActiveChannel: (channelId: string | null) => { set({ activeChannelId: channelId }); + // Report the actively viewed conversation to the hub so the server can + // suppress push notifications for it; null clears the marker. + void syncActiveChannelMarker(channelId ?? null); }, // ------------------------------------------------------------------ @@ -851,6 +871,13 @@ export const useChatStore = create()( void get().joinChannel(activeChannelId); void get().loadNewerMessages(activeChannelId); } + // Re-report the active conversation so the server-side notification + // suppression marker 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); + } }, reset: () => { @@ -859,6 +886,7 @@ export const useChatStore = create()( lastTypingSentAt.clear(); lastMarkedSeq.clear(); pendingChatbotMessages.clear(); + pendingActiveChannelSync = null; clearChatbotTypingTimeout(); if (outboxDrainTimer) { clearTimeout(outboxDrainTimer); diff --git a/src/translations/ar.json b/src/translations/ar.json index 406a35b..2f839c6 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -384,6 +384,8 @@ "open_assistant": "فتح المساعد", "create_conversation_failed": "تعذّر بدء المحادثة", "group_name": "اسم المجموعة", + "group_name_optional": "اسم المجموعة (اختياري)", + "unit": "الوحدة", "search_people": "البحث عن أشخاص", "no_people": "لم يتم العثور على أشخاص", "create_group_with": "إنشاء مجموعة ({{count}})", diff --git a/src/translations/de.json b/src/translations/de.json index e294634..aaea2ec 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -384,6 +384,8 @@ "open_assistant": "Assistent öffnen", "create_conversation_failed": "Unterhaltung konnte nicht gestartet werden", "group_name": "Gruppenname", + "group_name_optional": "Gruppenname (optional)", + "unit": "Einheit", "search_people": "Personen suchen", "no_people": "Keine Personen gefunden", "create_group_with": "Gruppe erstellen ({{count}})", diff --git a/src/translations/en.json b/src/translations/en.json index 4852e3b..0d4f919 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -384,6 +384,8 @@ "open_assistant": "Open Assistant", "create_conversation_failed": "Could not start the conversation", "group_name": "Group name", + "group_name_optional": "Group name (optional)", + "unit": "Unit", "search_people": "Search people", "no_people": "No people found", "create_group_with": "Create group ({{count}})", diff --git a/src/translations/es.json b/src/translations/es.json index ba3bb97..f5781fc 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -384,6 +384,8 @@ "open_assistant": "Abrir asistente", "create_conversation_failed": "No se pudo iniciar la conversación", "group_name": "Nombre del grupo", + "group_name_optional": "Nombre del grupo (opcional)", + "unit": "Unidad", "search_people": "Buscar personas", "no_people": "No se encontraron personas", "create_group_with": "Crear grupo ({{count}})", diff --git a/src/translations/fr.json b/src/translations/fr.json index b8c5bad..3ece4bf 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -384,6 +384,8 @@ "open_assistant": "Ouvrir l'assistant", "create_conversation_failed": "Impossible de démarrer la conversation", "group_name": "Nom du groupe", + "group_name_optional": "Nom du groupe (facultatif)", + "unit": "Unité", "search_people": "Rechercher des personnes", "no_people": "Aucune personne trouvée", "create_group_with": "Créer un groupe ({{count}})", diff --git a/src/translations/it.json b/src/translations/it.json index 50c048a..91ac428 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -384,6 +384,8 @@ "open_assistant": "Apri assistente", "create_conversation_failed": "Impossibile avviare la conversazione", "group_name": "Nome del gruppo", + "group_name_optional": "Nome del gruppo (facoltativo)", + "unit": "Unità", "search_people": "Cerca persone", "no_people": "Nessuna persona trovata", "create_group_with": "Crea gruppo ({{count}})", diff --git a/src/translations/pl.json b/src/translations/pl.json index 36f8a1b..e2480e6 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -384,6 +384,8 @@ "open_assistant": "Otwórz asystenta", "create_conversation_failed": "Nie można rozpocząć rozmowy", "group_name": "Nazwa grupy", + "group_name_optional": "Nazwa grupy (opcjonalnie)", + "unit": "Jednostka", "search_people": "Szukaj osób", "no_people": "Nie znaleziono osób", "create_group_with": "Utwórz grupę ({{count}})", diff --git a/src/translations/sv.json b/src/translations/sv.json index e036cc3..cdd191c 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -384,6 +384,8 @@ "open_assistant": "Öppna assistent", "create_conversation_failed": "Det gick inte att starta konversationen", "group_name": "Gruppnamn", + "group_name_optional": "Gruppnamn (valfritt)", + "unit": "Enhet", "search_people": "Sök personer", "no_people": "Inga personer hittades", "create_group_with": "Skapa grupp ({{count}})", diff --git a/src/translations/uk.json b/src/translations/uk.json index 17fef41..7e28313 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -384,6 +384,8 @@ "open_assistant": "Відкрити асистента", "create_conversation_failed": "Не вдалося розпочати розмову", "group_name": "Назва групи", + "group_name_optional": "Назва групи (необов'язково)", + "unit": "Підрозділ", "search_people": "Пошук людей", "no_people": "Людей не знайдено", "create_group_with": "Створити групу ({{count}})",