From 9b16243d59da4111f905b978c6c7526e8d43fe3d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 31 Jul 2026 12:48:16 -0700 Subject: [PATCH] RG-T117 Chat and Chatbot Entrypoints --- env.js | 2 + src/api/chat/chat.ts | 255 +++++ src/api/chat/chatbot.ts | 29 + src/app/(app)/_layout.tsx | 19 + src/app/(app)/chat.tsx | 194 ++++ src/app/(app)/chatbot.tsx | 121 +++ src/app/chat/[channelId].tsx | 322 +++++++ src/app/chat/thread/[messageId].tsx | 132 +++ src/components/chat/ack-banner.tsx | 37 + src/components/chat/chat-utils.ts | 134 +++ src/components/chat/gif-picker-sheet.tsx | 103 ++ src/components/chat/message-actions-sheet.tsx | 176 ++++ src/components/chat/message-bubble.tsx | 188 ++++ src/components/chat/message-composer.tsx | 166 ++++ .../chat/new-conversation-sheet.tsx | 189 ++++ src/components/chat/typing-indicator.tsx | 51 + src/components/sidebar/sidebar-content.tsx | 24 +- src/hooks/use-signalr-lifecycle.ts | 10 +- src/lib/utils.ts | 7 + src/models/v4/chat/chatEnums.ts | 69 ++ src/models/v4/chat/chatEvents.ts | 59 ++ src/models/v4/chat/chatInputs.ts | 67 ++ src/models/v4/chat/chatModels.ts | 166 ++++ src/models/v4/chat/chatbotModels.ts | 21 + src/models/v4/chat/index.ts | 6 + src/models/v4/chat/outbox.ts | 26 + src/services/push-notification.ts | 24 + src/services/signalr.service.ts | 4 +- src/stores/chat/store.ts | 886 ++++++++++++++++++ src/stores/signalr/signalr-store.ts | 131 +++ src/translations/en.json | 75 ++ 31 files changed, 3686 insertions(+), 7 deletions(-) create mode 100644 src/api/chat/chat.ts create mode 100644 src/api/chat/chatbot.ts create mode 100644 src/app/(app)/chat.tsx create mode 100644 src/app/(app)/chatbot.tsx create mode 100644 src/app/chat/[channelId].tsx create mode 100644 src/app/chat/thread/[messageId].tsx create mode 100644 src/components/chat/ack-banner.tsx create mode 100644 src/components/chat/chat-utils.ts create mode 100644 src/components/chat/gif-picker-sheet.tsx create mode 100644 src/components/chat/message-actions-sheet.tsx create mode 100644 src/components/chat/message-bubble.tsx create mode 100644 src/components/chat/message-composer.tsx create mode 100644 src/components/chat/new-conversation-sheet.tsx create mode 100644 src/components/chat/typing-indicator.tsx create mode 100644 src/models/v4/chat/chatEnums.ts create mode 100644 src/models/v4/chat/chatEvents.ts create mode 100644 src/models/v4/chat/chatInputs.ts create mode 100644 src/models/v4/chat/chatModels.ts create mode 100644 src/models/v4/chat/chatbotModels.ts create mode 100644 src/models/v4/chat/index.ts create mode 100644 src/models/v4/chat/outbox.ts create mode 100644 src/stores/chat/store.ts diff --git a/env.js b/env.js index 1487f18a..6e6f7599 100644 --- a/env.js +++ b/env.js @@ -85,6 +85,7 @@ const client = z.object({ RESGRID_API_URL: z.string(), CHANNEL_HUB_NAME: z.string(), REALTIME_GEO_HUB_NAME: z.string(), + CHAT_HUB_NAME: z.string(), LOGGING_KEY: z.string(), APP_KEY: z.string(), UNIT_MAPBOX_PUBKEY: z.string(), @@ -118,6 +119,7 @@ const _clientEnv = { RESGRID_API_URL: process.env.UNIT_RESGRID_API_URL || '/api/v4', CHANNEL_HUB_NAME: process.env.UNIT_CHANNEL_HUB_NAME || 'eventingHub', REALTIME_GEO_HUB_NAME: process.env.UNIT_REALTIME_GEO_HUB_NAME || 'geolocationHub', + CHAT_HUB_NAME: process.env.UNIT_CHAT_HUB_NAME || 'chatHub', LOGGING_KEY: process.env.UNIT_LOGGING_KEY || '', APP_KEY: process.env.UNIT_APP_KEY || '', IS_MOBILE_APP: 'true', diff --git a/src/api/chat/chat.ts b/src/api/chat/chat.ts new file mode 100644 index 00000000..ffe4b289 --- /dev/null +++ b/src/api/chat/chat.ts @@ -0,0 +1,255 @@ +import { getBaseApiUrl } from '@/lib/storage/app'; +import { + type AddMembersInput, + type AddReactionInput, + type ChatAckResultData, + type ChatActionResult, + type ChatAttachmentUploadedResult, + type ChatChannelResultData, + type ChatMemberResultData, + type ChatMessageResultData, + type ChatV4Response, + type CreateAdHocChannelInput, + type CreateDirectMessageInput, + type EditMessageInput, + type FlagMessageInput, + type GetChatPresenceResult, + type GifResultData, + type MarkReadInput, + type SendChatMessageInput, + type SetNotificationPreferenceInput, + type UpdateChannelInput, +} from '@/models/v4/chat'; +import useAuthStore from '@/stores/auth/store'; + +import { api } from '../common/client'; + +const CHAT = '/Chat'; +const MODERATION = '/ChatModeration'; + +// --------------------------------------------------------------------------- +// Channels +// --------------------------------------------------------------------------- + +export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetChannels`, { + params: activeUnitId != null ? { activeUnitId } : undefined, + signal, + }); + return response.data; +}; + +export const getChannel = async (channelId: string, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetChannel`, { params: { channelId }, signal }); + return response.data; +}; + +export const createDirectMessage = async (input: CreateDirectMessageInput) => { + const response = await api.post>(`${CHAT}/CreateDirectMessage`, input); + return response.data; +}; + +export const createAdHocChannel = async (input: CreateAdHocChannelInput) => { + const response = await api.post>(`${CHAT}/CreateAdHocChannel`, input); + return response.data; +}; + +export const updateChannel = async (channelId: string, input: UpdateChannelInput) => { + const response = await api.put>(`${CHAT}/UpdateChannel`, input, { params: { channelId } }); + return response.data; +}; + +export const archiveChannel = async (channelId: string) => { + const response = await api.delete(`${CHAT}/ArchiveChannel`, { params: { channelId } }); + return response.data; +}; + +// --------------------------------------------------------------------------- +// Members +// --------------------------------------------------------------------------- + +export const getMembers = async (channelId: string, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetMembers`, { params: { channelId }, signal }); + return response.data; +}; + +export const addMembers = async (channelId: string, input: AddMembersInput) => { + const response = await api.post>(`${CHAT}/AddMembers`, input, { params: { channelId } }); + return response.data; +}; + +export const removeMember = async (channelId: string, userId: string) => { + const response = await api.delete(`${CHAT}/RemoveMember`, { params: { channelId, userId } }); + return response.data; +}; + +export const setNotificationPreference = async (channelId: string, input: SetNotificationPreferenceInput) => { + const response = await api.put(`${CHAT}/SetNotificationPreference`, input, { params: { channelId } }); + return response.data; +}; + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +export const getMessages = async (channelId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetMessages`, { + params: { channelId, beforeSeq, limit }, + signal, + }); + return response.data; +}; + +export const getMessagesAfter = async (channelId: string, afterSeq: number, limit = 50, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetMessagesAfter`, { + params: { channelId, afterSeq, limit }, + signal, + }); + return response.data; +}; + +export const getThread = async (messageId: string, beforeSeq?: number, limit = 50, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetThread`, { + params: { messageId, beforeSeq, limit }, + signal, + }); + return response.data; +}; + +export const sendMessage = async (channelId: string, input: SendChatMessageInput) => { + const response = await api.post>(`${CHAT}/SendMessage`, input, { params: { channelId } }); + return response.data; +}; + +export const editMessage = async (messageId: string, input: EditMessageInput) => { + const response = await api.put>(`${CHAT}/EditMessage`, input, { params: { messageId } }); + return response.data; +}; + +export const deleteMessage = async (messageId: string) => { + const response = await api.delete(`${CHAT}/DeleteMessage`, { params: { messageId } }); + return response.data; +}; + +// --------------------------------------------------------------------------- +// Reactions, acks, read pointers, pins +// --------------------------------------------------------------------------- + +export const addReaction = async (messageId: string, input: AddReactionInput) => { + const response = await api.post(`${CHAT}/AddReaction`, input, { params: { messageId } }); + return response.data; +}; + +export const removeReaction = async (messageId: string, emoji: string) => { + const response = await api.delete(`${CHAT}/RemoveReaction`, { params: { messageId, emoji } }); + return response.data; +}; + +export const ackMessage = async (messageId: string) => { + const response = await api.post(`${CHAT}/Ack`, {}, { params: { messageId } }); + return response.data; +}; + +export const getMyPendingAcks = async (signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetMyPendingAcks`, { signal }); + return response.data; +}; + +export const markRead = async (channelId: string, input: MarkReadInput) => { + const response = await api.put(`${CHAT}/MarkRead`, input, { params: { channelId } }); + return response.data; +}; + +export const pinMessage = async (messageId: string) => { + const response = await api.post(`${CHAT}/PinMessage`, {}, { params: { messageId } }); + return response.data; +}; + +export const unpinMessage = async (messageId: string) => { + const response = await api.delete(`${CHAT}/UnpinMessage`, { params: { messageId } }); + return response.data; +}; + +export const getPins = async (channelId: string, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/GetPins`, { params: { channelId }, signal }); + return response.data; +}; + +// --------------------------------------------------------------------------- +// Attachments +// --------------------------------------------------------------------------- + +export interface ChatUploadFile { + uri: string; + name: string; + type: string; +} + +export const uploadAttachment = async (channelId: string, messageId: string, file: ChatUploadFile) => { + const form = new FormData(); + // React Native FormData accepts { uri, name, type } file objects. + form.append('file', { uri: file.uri, name: file.name, type: file.type } as unknown as Blob); + + const response = await api.post(`${CHAT}/UploadAttachment`, form, { + params: { channelId, messageId }, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +}; + +/** Absolute URL for downloading an attachment's binary. */ +export const getChatAttachmentUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachment?attachmentId=${encodeURIComponent(attachmentId)}`; + +/** Absolute URL for downloading an attachment's thumbnail. */ +export const getChatAttachmentThumbnailUrl = (attachmentId: string): string => `${getBaseApiUrl()}${CHAT}/GetAttachmentThumbnail?attachmentId=${encodeURIComponent(attachmentId)}`; + +/** + * Image source (with bearer auth header) suitable for expo-image / RN Image + * when rendering a chat attachment. + */ +export const getChatAttachmentImageSource = (attachmentId: string) => { + const token = useAuthStore.getState().accessToken; + return { + uri: getChatAttachmentUrl(attachmentId), + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +}; + +// --------------------------------------------------------------------------- +// Search, GIFs, presence, flags, moderation +// --------------------------------------------------------------------------- + +export const searchMessages = async (q: string, channelId?: string, page = 0, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/Search`, { + params: { q, channelId, page }, + signal, + }); + return response.data; +}; + +export const searchGifs = async (q?: string, limit = 25, offset = 0, signal?: AbortSignal) => { + const response = await api.get>(`${CHAT}/SearchGifs`, { + params: { q, limit, offset }, + signal, + }); + return response.data; +}; + +export const getPresence = async (userIds: string[], signal?: AbortSignal) => { + const response = await api.get(`${CHAT}/GetPresence`, { + params: { userIds: userIds.join(',') }, + signal, + }); + return response.data; +}; + +export const flagMessage = async (messageId: string, input: FlagMessageInput) => { + const response = await api.post(`${CHAT}/FlagMessage`, input, { params: { messageId } }); + return response.data; +}; + +/** Department-admin / moderator hard delete of a message. */ +export const moderatorDeleteMessage = async (messageId: string, reason: string) => { + const response = await api.post(`${MODERATION}/DeleteMessage`, {}, { params: { messageId, reason } }); + return response.data; +}; diff --git a/src/api/chat/chatbot.ts b/src/api/chat/chatbot.ts new file mode 100644 index 00000000..383b9f5f --- /dev/null +++ b/src/api/chat/chatbot.ts @@ -0,0 +1,29 @@ +import { type ChatbotChannelResponse, type ChatbotSendResponse, type ChatbotSessionResponse } from '@/models/v4/chat'; + +import { api } from '../common/client'; + +const CHATBOT = '/Chatbot'; + +/** Gets (creating if needed) the caller's chatbot conversation channel. */ +export const getChatbotChannel = async (signal?: AbortSignal) => { + const response = await api.get(`${CHATBOT}/GetChatChannel`, { signal }); + return response.data; +}; + +/** + * Sends a message to the chatbot. The reply arrives asynchronously in the same + * channel over SignalR (chatbotMessageReceived). Idempotent via clientMessageId. + */ +export const sendChatbotMessage = async (text: string, clientMessageId: string) => { + const response = await api.post(`${CHATBOT}/SendChatMessage`, { + Text: text, + ClientMessageId: clientMessageId, + }); + return response.data; +}; + +/** Resets the chatbot conversational session (message history is retained). */ +export const newChatbotSession = async () => { + const response = await api.post(`${CHATBOT}/NewChatSession`, {}); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 1128f635..055a91b1 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -170,6 +170,16 @@ export default function TabLayout() { // available now. The two hub connects are independent. await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]); + // Connect the realtime chat hub (best-effort; chat may be disabled per department) + try { + await useSignalRStore.getState().connectChatHub(); + } catch (error) { + logger.warn({ + message: 'Failed to connect SignalR chat hub during initialization', + context: { error, platform: Platform.OS }, + }); + } + hasInitialized.current = true; // Evict expired/capped API cache entries once per cold start. @@ -478,6 +488,11 @@ export default function TabLayout() { [t, settingsIcon] ); + // Chat and the assistant are reached from the sidebar, not the bottom tab bar, + // so they are registered as hidden routes (href: null) that render their own header. + const chatOptions = useMemo(() => ({ href: null, headerShown: false as const }), []); + const chatbotOptions = useMemo(() => ({ href: null, headerShown: false as const }), []); + if (isFirstTime) { return ; } @@ -535,6 +550,10 @@ export default function TabLayout() { + + + + {/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */} diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx new file mode 100644 index 00000000..30724a54 --- /dev/null +++ b/src/app/(app)/chat.tsx @@ -0,0 +1,194 @@ +import { type Href, useFocusEffect, useRouter } from 'expo-router'; +import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView } from 'react-native'; + +import { AckBanner } from '@/components/chat/ack-banner'; +import { getChannelDisplayName, groupChannels } from '@/components/chat/chat-utils'; +import { NewConversationSheet } from '@/components/chat/new-conversation-sheet'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetItem, ActionsheetItemText } from '@/components/ui/actionsheet'; +import { Avatar, AvatarFallbackText } from '@/components/ui/avatar'; +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Fab, FabIcon } from '@/components/ui/fab'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat'; +import { useChatStore } from '@/stores/chat/store'; + +function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { + const unread = channel.UnreadCount > 0; + const isDm = channel.ChannelType === ChatChannelType.DirectMessage; + + const Leading = () => { + if (isDm) { + return ( + + {getChannelDisplayName(channel)} + + ); + } + const isIncident = channel.ChannelType === ChatChannelType.Incident || channel.ChannelType === ChatChannelType.IncidentLane || channel.ChannelType === ChatChannelType.IncidentCommand; + const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users; + return ( + + + + ); + }; + + return ( + + + + + + {getChannelDisplayName(channel)} + + {channel.Topic ? ( + + {channel.Topic} + + ) : null} + + {unread ? ( + + {channel.UnreadCount > 99 ? '99+' : String(channel.UnreadCount)} + + ) : null} + + + ); +} + +function Section({ title, channels, onOpen }: { title: string; channels: ChatChannelResultData[]; onOpen: (id: string) => void }) { + if (channels.length === 0) return null; + return ( + + {title} + {channels.map((channel) => ( + onOpen(channel.ChatChannelId)} /> + ))} + + ); +} + +export default function ChatScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const channels = useChatStore((s) => s.channels); + const isLoading = useChatStore((s) => s.isLoadingChannels); + const pendingAcks = useChatStore((s) => s.pendingAcks); + const [fabOpen, setFabOpen] = useState(false); + const [newMode, setNewMode] = useState<'dm' | 'group' | null>(null); + + useFocusEffect( + useCallback(() => { + useChatStore.getState().fetchChannels(); + useChatStore.getState().fetchPendingAcks(); + }, []) + ); + + const grouped = groupChannels(channels); + + const openChannel = useCallback( + (channelId: string) => { + router.push(`/chat/${channelId}` as Href); + }, + [router] + ); + + return ( + + + + {/* In-screen toolbar (the app drawer provides the top nav bar). */} + + + + {t('chat.title')} + + router.push('/chatbot' as Href)} accessibilityLabel={t('chat.assistant')}> + + + + + useChatStore.getState().acknowledgeMessage(messageId)} /> + + useChatStore.getState().fetchChannels()} />} + > + {channels.length === 0 && !isLoading ? ( + + + {t('chat.empty')} + + ) : ( + <> +
+
+
+
+ + )} + + + + setFabOpen(true)} className="bg-primary-600"> + + + + {/* Choose new-conversation type */} + setFabOpen(false)}> + + + + + + { + setFabOpen(false); + setNewMode('dm'); + }} + > + + {t('chat.new_direct_message')} + + { + setFabOpen(false); + setNewMode('group'); + }} + > + + {t('chat.new_group')} + + { + setFabOpen(false); + router.push('/chatbot' as Href); + }} + > + + {t('chat.open_assistant')} + + + + + setNewMode(null)} + onCreated={(channelId) => { + setNewMode(null); + openChannel(channelId); + }} + /> + + ); +} diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx new file mode 100644 index 00000000..b6981eb8 --- /dev/null +++ b/src/app/(app)/chatbot.tsx @@ -0,0 +1,121 @@ +import { useFocusEffect } from 'expo-router'; +import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { MessageBubble } from '@/components/chat/message-bubble'; +import { TypingDots } from '@/components/chat/typing-indicator'; +import { Box } from '@/components/ui/box'; +import { Center } from '@/components/ui/center'; +import { FlatList } from '@/components/ui/flat-list'; +import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; +import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type ChatMessageResultData } from '@/models/v4/chat'; +import { useChatStore } from '@/stores/chat/store'; +import useAuthStore from '@/stores/auth/store'; + +export default function ChatbotScreen() { + const { t } = useTranslation(); + const currentUserId = useAuthStore((s) => s.userId); + const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); + const chatbotTyping = useChatStore((s) => s.chatbotTyping); + const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); + const [text, setText] = useState(''); + + useFocusEffect( + useCallback(() => { + const store = useChatStore.getState(); + void store.initChatbot(); + return () => { + useChatStore.getState().setActiveChannel(null); + }; + }, []) + ); + + // Keep the assistant channel active while viewing so incoming messages don't inflate unread. + useFocusEffect( + useCallback(() => { + if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId); + }, [chatbotChannelId]) + ); + + const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); + + const send = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + setText(''); + void useChatStore.getState().sendChatbotMessage(trimmed); + }, [text]); + + const renderItem = useCallback( + ({ item }: { item: ChatMessageResultData }) => ( + undefined} onToggleReaction={() => undefined} /> + ), + [currentUserId] + ); + + return ( + + + + {/* Distinct assistant header */} + + + + + + + {t('chatbot.title')} + {t('chatbot.subtitle')} + + + useChatStore.getState().newChatbotSession()} + accessibilityLabel={t('chatbot.new_session')} + > + + {t('chatbot.new_session')} + + + + + {inverted.length === 0 ? ( +
+ + {t('chatbot.empty')} +
+ ) : ( + item.ChatMessageId} renderItem={renderItem} contentContainerStyle={{ paddingVertical: 8 }} /> + )} + + {chatbotTyping ? ( + + + + + + + ) : null} + + + + + + + + + + + +
+
+ ); +} diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx new file mode 100644 index 00000000..661f1627 --- /dev/null +++ b/src/app/chat/[channelId].tsx @@ -0,0 +1,322 @@ +import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { Circle } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; +import { Image } from 'expo-image'; + +import { getPresence, uploadAttachment } from '@/api/chat/chat'; +import { AckBanner } from '@/components/chat/ack-banner'; +import { copyToClipboard, getChannelDisplayName } 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'; +import { MessageComposer } from '@/components/chat/message-composer'; +import { TypingIndicator } 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 { HStack } from '@/components/ui/hstack'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; +import { VStack } from '@/components/ui/vstack'; +import { ChatChannelType, ChatMessagePriority, ChatMessageType, type ChatMessageResultData, type GifResultData } from '@/models/v4/chat'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useChatStore } from '@/stores/chat/store'; +import { securityStore } from '@/stores/security/store'; +import { useToastStore } from '@/stores/toast/store'; +import useAuthStore from '@/stores/auth/store'; + +export default function ChannelConversationScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ channelId: string }>(); + const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; + + const currentUserId = useAuthStore((s) => s.userId); + const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; + // Unit-app chat identity: messages post as the active unit ("Engine 6"). + const activeUnit = useCoreStore((s) => s.activeUnit); + + const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); + const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); + const typing = useChatStore((s) => (channelId ? s.typingByChannel[channelId] : undefined)); + const members = useChatStore((s) => (channelId ? s.membersByChannel[channelId] : undefined)); + const presence = useChatStore((s) => s.presence); + const pendingAcks = useChatStore((s) => s.pendingAcks); + const loading = useChatStore((s) => (channelId ? s.loadingMessagesByChannel[channelId] : false)); + + const [gifOpen, setGifOpen] = useState(false); + const [actionsMessage, setActionsMessage] = useState(null); + const [editMessage, setEditMessage] = useState(null); + const [editText, setEditText] = useState(''); + const [imageUri, setImageUri] = useState(null); + const [presenceIds, setPresenceIds] = useState>(new Set()); + + const isDm = channel?.ChannelType === ChatChannelType.DirectMessage; + const showSender = !isDm; + + // Newest-first for the inverted list. + const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); + + // Mount: activate channel, join hub, load history and members. + useFocusEffect( + useCallback(() => { + if (!channelId) return; + const store = useChatStore.getState(); + store.setActiveChannel(channelId); + void store.joinChannel(channelId); + void store.loadInitialMessages(channelId); + void store.fetchMembers(channelId); + return () => { + useChatStore.getState().setActiveChannel(null); + }; + }, [channelId]) + ); + + // Fetch presence for the channel members (for the header online dot). + useEffect(() => { + const ids = (members ?? []).map((m) => m.UserId).filter((id): id is string => !!id && id !== currentUserId); + if (ids.length === 0) return; + getPresence(ids) + .then((result) => setPresenceIds(new Set(result.OnlineUserIds ?? []))) + .catch(() => undefined); + }, [members, currentUserId]); + + // Mark read whenever the newest message changes while viewing. + useEffect(() => { + if (channelId && inverted.length > 0) { + void useChatStore.getState().markChannelRead(channelId); + } + }, [channelId, inverted.length]); + + const otherOnline = useMemo(() => { + if (!isDm) return false; + const other = (members ?? []).find((m) => m.UserId && m.UserId !== currentUserId); + if (!other?.UserId) return false; + return presence.has(other.UserId) || presenceIds.has(other.UserId); + }, [isDm, members, currentUserId, presence, presenceIds]); + + const channelAcks = useMemo(() => pendingAcks.filter((a) => a.ChatChannelId === channelId), [pendingAcks, channelId]); + + const typingNames = useMemo(() => { + const now = Date.now(); + return (typing ?? []).filter((u) => u.expiresAt > now).map((u) => u.displayName || t('chat.someone')); + }, [typing, t]); + + // ---- send handlers ---- + const handleSendText = useCallback( + (body: string, urgent: boolean) => { + if (!channelId) return; + void useChatStore.getState().sendMessage({ channelId, body, priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal }); + }, + [channelId] + ); + + 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 }); + void useChatStore.getState().sendMessage({ channelId, body: gif.Title ?? 'GIF', messageType: ChatMessageType.Gif, metadataJson: metadata }); + }, + [channelId] + ); + + const handleSendLocation = useCallback( + (latitude: number, longitude: number, urgent: boolean) => { + if (!channelId) return; + const metadata = JSON.stringify({ Latitude: latitude, Longitude: longitude }); + void useChatStore.getState().sendMessage({ + channelId, + body: t('chat.shared_location'), + messageType: ChatMessageType.Location, + metadataJson: metadata, + priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal, + }); + }, + [channelId, t] + ); + + // Image send: optimistic bubble, send an Image message, then upload the file. + const handleSendImage = useCallback( + async (uri: string, urgent: boolean) => { + if (!channelId) return; + const name = uri.split('/').pop() || `photo-${Date.now()}.jpg`; + await useChatStore.getState().sendMessage({ + channelId, + body: '', + messageType: ChatMessageType.Image, + priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal, + localAttachmentUri: uri, + }); + // Find the just-sent (now server-confirmed) message by its local uri to attach the file. + const list = useChatStore.getState().messagesByChannel[channelId] ?? []; + const sent = [...list].reverse().find((m) => m._localAttachmentUri === uri && !m.ChatMessageId.startsWith('local-')); + if (sent) { + try { + await uploadAttachment(channelId, sent.ChatMessageId, { uri, name, type: 'image/jpeg' }); + } catch { + useToastStore.getState().showToast('error', t('chat.attachment_failed')); + } + } + }, + [channelId, t] + ); + + const handleToggleReaction = useCallback( + (message: ChatMessageResultData, emoji: string, mine: boolean) => { + if (!channelId) return; + if (mine) void useChatStore.getState().removeReaction(message.ChatMessageId, channelId, emoji); + else void useChatStore.getState().addReaction(message.ChatMessageId, channelId, emoji); + }, + [channelId] + ); + + const openThread = useCallback( + (message: ChatMessageResultData) => { + router.push(`/chat/thread/${message.ChatMessageId}?channelId=${channelId ?? ''}` as Href); + }, + [router, channelId] + ); + + const renderItem = useCallback( + ({ item }: { item: ChatMessageResultData }) => ( + m.ClientMessageId && useChatStore.getState().retryOutboxItem(m.ClientMessageId)} + onPressImage={setImageUri} + /> + ), + [currentUserId, showSender, handleToggleReaction, openThread] + ); + + const title = channel ? getChannelDisplayName(channel) : t('chat.title'); + + return ( + + (isDm ? : undefined), + }} + /> + + useChatStore.getState().acknowledgeMessage(messageId)} /> + + + {loading && inverted.length === 0 ? ( +
+ +
+ ) : ( + item.ChatMessageId} + renderItem={renderItem} + onEndReached={() => channelId && useChatStore.getState().loadOlderMessages(channelId)} + onEndReachedThreshold={0.3} + contentContainerStyle={{ paddingVertical: 8 }} + /> + )} + + + + {/* Unit-app identity chip: shows which unit the current user is chatting as. */} + {activeUnit ? ( + + + {t('chat.chatting_as', { name: activeUnit.Name })} + + ) : null} + + setGifOpen(true)} + onTyping={(isTyping) => channelId && useChatStore.getState().sendTyping(channelId, isTyping)} + disabled={channel?.IsLocked && !isModerator} + /> +
+ + setGifOpen(false)} onSelect={handleSendGif} /> + + setActionsMessage(null)} + isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId} + isModerator={isModerator} + onReact={(m, emoji) => handleToggleReaction(m, emoji, m.Reactions.some((r) => r.Emoji === emoji && r.UserId === currentUserId))} + onReply={openThread} + onCopy={async (m) => { + const ok = await copyToClipboard(m.Body ?? ''); + useToastStore.getState().showToast(ok ? 'success' : 'info', ok ? t('chat.copied') : t('chat.copy_unavailable')); + }} + onEdit={(m) => { + setEditMessage(m); + setEditText(m.Body ?? ''); + }} + onDelete={(m) => channelId && useChatStore.getState().deleteMessage(m.ChatMessageId, channelId)} + onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)} + onTogglePin={(m, pinned) => channelId && useChatStore.getState().togglePin(m.ChatMessageId, channelId, pinned)} + onModeratorDelete={(m) => channelId && useChatStore.getState().moderatorDeleteMessage(m.ChatMessageId, channelId, t('chat.moderator_removed'))} + /> + + {/* Edit message sheet */} + setEditMessage(null)}> + + + + + + + {t('chat.edit_message')} + + + + + + + {/* Full-screen image preview */} + setImageUri(null)} snapPoints={[80]}> + + + + + + {imageUri ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx new file mode 100644 index 00000000..d4d9011e --- /dev/null +++ b/src/app/chat/thread/[messageId].tsx @@ -0,0 +1,132 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { getThread } from '@/api/chat/chat'; +import { MessageBubble } from '@/components/chat/message-bubble'; +import { MessageComposer } from '@/components/chat/message-composer'; +import { Box } from '@/components/ui/box'; +import { Divider } from '@/components/ui/divider'; +import { FlatList } from '@/components/ui/flat-list'; +import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; +import { ChatMessagePriority, ChatMessageType, type ChatMessageResultData } from '@/models/v4/chat'; +import { useChatStore } from '@/stores/chat/store'; +import useAuthStore from '@/stores/auth/store'; + +export default function ThreadScreen() { + const { t } = useTranslation(); + const params = useLocalSearchParams<{ messageId: string; channelId: string }>(); + const messageId = Array.isArray(params.messageId) ? params.messageId[0] : params.messageId; + const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; + + const currentUserId = useAuthStore((s) => s.userId); + const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); + const [fetchedReplies, setFetchedReplies] = useState([]); + + const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]); + + useEffect(() => { + if (!messageId) return; + getThread(messageId, undefined, 50) + .then((response) => setFetchedReplies(response.Data ?? [])) + .catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } })); + }, [messageId]); + + // Merge fetched replies with any realtime/optimistic replies already in the channel cache. + const replies = useMemo(() => { + const map = new Map(); + for (const m of fetchedReplies) map.set(m.ChatMessageId, m); + for (const m of channelMessages ?? []) { + if (m.ThreadRootMessageId === messageId) { + const existing = map.get(m.ChatMessageId); + map.set(m.ChatMessageId, existing ? { ...existing, ...m } : m); + } + } + return Array.from(map.values()).sort((a, b) => a.MessageSeq - b.MessageSeq); + }, [fetchedReplies, channelMessages, messageId]); + + const inverted = useMemo(() => replies.slice().reverse(), [replies]); + + const handleSendText = useCallback( + (body: string, urgent: boolean) => { + if (!channelId || !messageId) return; + void useChatStore.getState().sendMessage({ channelId, body, threadRootMessageId: messageId, priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal }); + }, + [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; + void useChatStore.getState().sendMessage({ + channelId, + body: t('chat.shared_location'), + messageType: ChatMessageType.Location, + metadataJson: JSON.stringify({ Latitude: latitude, Longitude: longitude }), + threadRootMessageId: messageId, + priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal, + }); + }, + [channelId, messageId, t] + ); + + const renderItem = useCallback( + ({ item }: { item: ChatMessageResultData }) => ( + undefined} + onToggleReaction={(m, emoji, mine) => { + if (!channelId) return; + if (mine) void useChatStore.getState().removeReaction(m.ChatMessageId, channelId, emoji); + else void useChatStore.getState().addReaction(m.ChatMessageId, channelId, emoji); + }} + /> + ), + [currentUserId, channelId] + ); + + return ( + + + + + {root ? ( + + {t('chat.original_message')} + undefined} onToggleReaction={() => undefined} /> + + ) : null} + + + + item.ChatMessageId} + renderItem={renderItem} + contentContainerStyle={{ paddingVertical: 8 }} + /> + + undefined} + onSendLocation={handleSendLocation} + onOpenGif={handleSendGif} + onTyping={() => undefined} + placeholder={t('chat.reply_placeholder')} + /> + + + ); +} diff --git a/src/components/chat/ack-banner.tsx b/src/components/chat/ack-banner.tsx new file mode 100644 index 00000000..50b999f6 --- /dev/null +++ b/src/components/chat/ack-banner.tsx @@ -0,0 +1,37 @@ +import { AlertTriangle } from 'lucide-react-native'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonText } from '@/components/ui/button'; +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { type ChatAckResultData } from '@/models/v4/chat'; + +interface AckBannerProps { + acks: ChatAckResultData[]; + onAcknowledge: (messageId: string) => void; +} + +/** Sticky banner prompting the user to acknowledge the oldest pending urgent message. */ +export function AckBanner({ acks, onAcknowledge }: AckBannerProps) { + const { t } = useTranslation(); + if (acks.length === 0) return null; + + const oldest = acks.reduce((a, b) => (new Date(a.RequiredOn).getTime() <= new Date(b.RequiredOn).getTime() ? a : b)); + + return ( + + + + + {t('chat.ack_required')} + {acks.length > 1 ? t('chat.ack_pending_count', { count: acks.length }) : t('chat.ack_pending_one')} + + + + + ); +} diff --git a/src/components/chat/chat-utils.ts b/src/components/chat/chat-utils.ts new file mode 100644 index 00000000..38a00e99 --- /dev/null +++ b/src/components/chat/chat-utils.ts @@ -0,0 +1,134 @@ +import { getAvatarUrl } from '@/lib/utils'; +import { ChatChannelType, type ChatChannelResultData, type ChatGifMetadata, type ChatImageMetadata, type ChatLocationMetadata } from '@/models/v4/chat'; + +/** Buckets used to group the channel list into sections. */ +export interface GroupedChannels { + directMessages: ChatChannelResultData[]; + channels: ChatChannelResultData[]; + incidents: ChatChannelResultData[]; + assistant: ChatChannelResultData[]; +} + +export function groupChannels(channels: ChatChannelResultData[]): GroupedChannels { + const grouped: GroupedChannels = { directMessages: [], channels: [], incidents: [], assistant: [] }; + for (const channel of channels) { + if (channel.IsArchived) continue; + switch (channel.ChannelType) { + case ChatChannelType.DirectMessage: + grouped.directMessages.push(channel); + break; + case ChatChannelType.Incident: + case ChatChannelType.IncidentLane: + case ChatChannelType.IncidentCommand: + grouped.incidents.push(channel); + break; + case ChatChannelType.Chatbot: + grouped.assistant.push(channel); + break; + default: + grouped.channels.push(channel); + break; + } + } + const byRecent = (a: ChatChannelResultData, b: ChatChannelResultData) => new Date(b.LastMessageOn ?? 0).getTime() - new Date(a.LastMessageOn ?? 0).getTime(); + grouped.directMessages.sort(byRecent); + grouped.channels.sort(byRecent); + grouped.incidents.sort(byRecent); + return grouped; +} + +export function getChannelDisplayName(channel: ChatChannelResultData): string { + if (channel.Name && channel.Name.trim().length > 0) return channel.Name; + if (channel.ChannelType === ChatChannelType.DirectMessage) return 'Direct Message'; + return 'Channel'; +} + +export function isDirectMessage(channel: ChatChannelResultData): boolean { + return channel.ChannelType === ChatChannelType.DirectMessage; +} + +export function getPersonAvatarUrl(userId?: string | null): string | undefined { + if (!userId) return undefined; + return getAvatarUrl(userId); +} + +export function parseMetadata(metadataJson?: string | null): T | null { + if (!metadataJson) return null; + try { + return JSON.parse(metadataJson) as T; + } catch { + return null; + } +} + +export function parseLocationMetadata(metadataJson?: string | null): ChatLocationMetadata | null { + return parseMetadata(metadataJson); +} + +export function parseGifMetadata(metadataJson?: string | null): ChatGifMetadata | null { + return parseMetadata(metadataJson); +} + +export function parseImageMetadata(metadataJson?: string | null): ChatImageMetadata | null { + return parseMetadata(metadataJson); +} + +const URL_REGEX = /(https?:\/\/[^\s]+)/g; + +export interface TextSegment { + text: string; + isLink: boolean; +} + +/** Splits a message body into plain-text and link segments for rendering. */ +export function linkifySegments(body: string): TextSegment[] { + if (!body) return []; + const segments: TextSegment[] = []; + let lastIndex = 0; + const matches = body.matchAll(URL_REGEX); + for (const match of matches) { + const index = match.index ?? 0; + if (index > lastIndex) segments.push({ text: body.slice(lastIndex, index), isLink: false }); + segments.push({ text: match[0], isLink: true }); + lastIndex = index + match[0].length; + } + if (lastIndex < body.length) segments.push({ text: body.slice(lastIndex), isLink: false }); + return segments; +} + +export function hasLink(body?: string | null): boolean { + if (!body) return false; + return URL_REGEX.test(body); +} + +/** + * Copies text to the clipboard. Works on web/Electron via the async Clipboard + * API; native returns false (no clipboard native module is installed) so callers + * can surface an appropriate message. + */ +export async function copyToClipboard(text: string): Promise { + try { + const nav = (globalThis as unknown as { navigator?: { clipboard?: { writeText?: (value: string) => Promise } } }).navigator; + if (nav?.clipboard?.writeText) { + await nav.clipboard.writeText(text); + return true; + } + } catch { + // ignore and fall through + } + return false; +} + +/** Relative-ish short time label for message rows and channel list. */ +export function formatShortTime(iso?: string | null): string { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + const now = Date.now(); + const diffMs = now - date.getTime(); + const oneDay = 24 * 60 * 60 * 1000; + if (diffMs < oneDay && now >= date.getTime()) { + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} diff --git a/src/components/chat/gif-picker-sheet.tsx b/src/components/chat/gif-picker-sheet.tsx new file mode 100644 index 00000000..c801dea6 --- /dev/null +++ b/src/components/chat/gif-picker-sheet.tsx @@ -0,0 +1,103 @@ +import { Image } from 'expo-image'; +import { Search } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { searchGifs } from '@/api/chat/chat'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; +import { Box } from '@/components/ui/box'; +import { Center } from '@/components/ui/center'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { ScrollView } from '@/components/ui/scroll-view'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { logger } from '@/lib/logging'; +import { type GifResultData } from '@/models/v4/chat'; + +interface GifPickerSheetProps { + isOpen: boolean; + onClose: () => void; + onSelect: (gif: GifResultData) => void; +} + +export function GifPickerSheet({ isOpen, onClose, onSelect }: GifPickerSheetProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [gifs, setGifs] = useState([]); + const [loading, setLoading] = useState(false); + const debounceRef = useRef | null>(null); + + const runSearch = useCallback(async (q: string) => { + setLoading(true); + try { + const response = await searchGifs(q || undefined, 24, 0); + setGifs(response.Data ?? []); + } catch (error) { + logger.debug({ message: 'chat: gif search failed', context: { error } }); + setGifs([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen) return; + runSearch(''); + }, [isOpen, runSearch]); + + const handleChange = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => runSearch(value), 400); + }; + + return ( + + + + + + + + + + + + + + + + + {loading ? ( +
+ +
+ ) : gifs.length === 0 ? ( +
+ {t('chat.no_gifs')} +
+ ) : ( + + + {gifs.map((gif) => ( + { + onSelect(gif); + onClose(); + }} + > + + + ))} + + + )} +
+
+ ); +} diff --git a/src/components/chat/message-actions-sheet.tsx b/src/components/chat/message-actions-sheet.tsx new file mode 100644 index 00000000..0c96b25e --- /dev/null +++ b/src/components/chat/message-actions-sheet.tsx @@ -0,0 +1,176 @@ +import { Copy, Flag, MessageSquare, Pencil, Pin, PinOff, ShieldX, Trash2 } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper, ActionsheetItem, ActionsheetItemText } from '@/components/ui/actionsheet'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { ChatFlagReason, ChatMessageType, type ChatMessageResultData } from '@/models/v4/chat'; + +const QUICK_REACTIONS = ['👍', '❤️', '😂', '🙏', '🔥', '✅']; + +interface MessageActionsSheetProps { + message: ChatMessageResultData | null; + isOpen: boolean; + onClose: () => void; + isOwn: boolean; + isModerator: boolean; + onReact: (message: ChatMessageResultData, emoji: string) => void; + onReply: (message: ChatMessageResultData) => void; + onCopy: (message: ChatMessageResultData) => void; + onEdit: (message: ChatMessageResultData) => void; + onDelete: (message: ChatMessageResultData) => void; + onFlag: (message: ChatMessageResultData, reason: number) => void; + onTogglePin: (message: ChatMessageResultData, pinned: boolean) => void; + onModeratorDelete: (message: ChatMessageResultData) => void; +} + +export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { + const { t } = useTranslation(); + const [mode, setMode] = useState<'actions' | 'flag'>('actions'); + + const close = () => { + setMode('actions'); + onClose(); + }; + + if (!message) return null; + + const isDeleted = !!message.DeletedOn; + const isText = message.MessageType === ChatMessageType.Text; + const isPinned = !!message.PinnedOn; + + const flagReasons: { reason: number; label: string }[] = [ + { reason: ChatFlagReason.Inappropriate, label: t('chat.flag_inappropriate') }, + { reason: ChatFlagReason.Harassment, label: t('chat.flag_harassment') }, + { reason: ChatFlagReason.Spam, label: t('chat.flag_spam') }, + { reason: ChatFlagReason.SensitiveInformation, label: t('chat.flag_sensitive') }, + { reason: ChatFlagReason.PolicyViolation, label: t('chat.flag_policy') }, + { reason: ChatFlagReason.Other, label: t('chat.flag_other') }, + ]; + + return ( + + + + + + + + {mode === 'flag' ? ( + <> + {t('chat.flag_reason')} + {flagReasons.map((item) => ( + { + onFlag(message, item.reason); + close(); + }} + > + {item.label} + + ))} + + ) : ( + <> + {!isDeleted ? ( + + {QUICK_REACTIONS.map((emoji) => ( + { + onReact(message, emoji); + close(); + }} + > + {emoji} + + ))} + + ) : null} + + { + onReply(message); + close(); + }} + > + + {t('chat.reply_in_thread')} + + + {isText && !isDeleted ? ( + { + onCopy(message); + close(); + }} + > + + {t('chat.copy')} + + ) : null} + + {isOwn && isText && !isDeleted ? ( + { + onEdit(message); + close(); + }} + > + + {t('chat.edit')} + + ) : null} + + {isOwn && !isDeleted ? ( + { + onDelete(message); + close(); + }} + > + + {t('chat.delete')} + + ) : null} + + {isModerator ? ( + { + onTogglePin(message, !isPinned); + close(); + }} + > + {isPinned ? : } + {isPinned ? t('chat.unpin') : t('chat.pin')} + + ) : null} + + {!isOwn && !isDeleted ? ( + setMode('flag')}> + + {t('chat.flag')} + + ) : null} + + {isModerator && !isDeleted ? ( + { + onModeratorDelete(message); + close(); + }} + > + + {t('chat.moderator_delete')} + + ) : null} + + )} + + + ); +} diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx new file mode 100644 index 00000000..4321e58d --- /dev/null +++ b/src/components/chat/message-bubble.tsx @@ -0,0 +1,188 @@ +import { Image } from 'expo-image'; +import { AlertTriangle, Clock, MapPin, MessageSquare, Pin, RefreshCw } from 'lucide-react-native'; +import React, { useMemo } from 'react'; +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 { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { ChatMessagePriority, ChatMessageType, type ChatMessageResultData } from '@/models/v4/chat'; + +import { formatShortTime, getPersonAvatarUrl, linkifySegments, parseGifMetadata, parseLocationMetadata } from './chat-utils'; + +interface MessageBubbleProps { + message: ChatMessageResultData; + isOwn: boolean; + showSender: boolean; + currentUserId: string | null; + onLongPress: (message: ChatMessageResultData) => void; + onToggleReaction: (message: ChatMessageResultData, emoji: string, mine: boolean) => void; + onOpenThread?: (message: ChatMessageResultData) => void; + onRetry?: (message: ChatMessageResultData) => void; + onPressImage?: (uri: string) => void; +} + +export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { + const { t } = useTranslation(); + + const groupedReactions = useMemo(() => { + const map = new Map(); + 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; + map.set(reaction.Emoji, current); + } + return Array.from(map.entries()); + }, [message.Reactions, currentUserId]); + + // System message: centered, subtle. + if (message.MessageType === ChatMessageType.System) { + return ( + + {message.Body} + + ); + } + + const isDeleted = !!message.DeletedOn; + const isUrgent = message.Priority === ChatMessagePriority.Urgent; + const isPending = message._localStatus === 'pending'; + const isFailed = message._localStatus === 'failed'; + + const bubbleTone = isOwn ? 'bg-primary-600' : 'bg-background-100'; + const textTone = isOwn ? 'text-white' : 'text-typography-900'; + const urgentClasses = isUrgent && !isOwn ? 'border-2 border-error-500 bg-error-50' : isUrgent && isOwn ? 'border-2 border-error-300' : ''; + + const renderBody = () => { + if (isDeleted) { + return {t('chat.message_deleted')}; + } + + if (message.MessageType === ChatMessageType.Image) { + 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}; + return ( + uri && onPressImage?.(uri)}> + + {message.Body ? {message.Body} : null} + + ); + } + + if (message.MessageType === ChatMessageType.Gif) { + const gif = parseGifMetadata(message.MetadataJson); + if (gif?.GifUrl) { + return ; + } + return {message.Body}; + } + + if (message.MessageType === ChatMessageType.Location) { + const loc = parseLocationMetadata(message.MetadataJson); + if (loc) { + return ( + Linking.openURL(`https://maps.google.com/?q=${loc.Latitude},${loc.Longitude}`)}> + + + {loc.Label ?? t('chat.shared_location')} + + + ); + } + } + + // Text (with inline links). + const segments = linkifySegments(message.Body ?? ''); + if (segments.length === 0) return {message.Body}; + return ( + + {segments.map((segment, index) => + segment.isLink ? ( + Linking.openURL(segment.text)}> + {segment.text} + + ) : ( + + {segment.text} + + ) + )} + + ); + }; + + return ( + + {!isOwn && showSender ? ( + + {message.SenderDisplayName ?? '?'} + {message.SenderUserId ? : null} + + ) : !isOwn ? ( + + ) : null} + + + {!isOwn && showSender && message.SenderDisplayName ? {message.SenderDisplayName} : null} + + onLongPress(message)} delayLongPress={250}> + + {isUrgent && !isDeleted ? ( + + + {t('chat.urgent')} + + ) : null} + {renderBody()} + + + + {groupedReactions.length > 0 ? ( + + {groupedReactions.map(([emoji, info]) => ( + onToggleReaction(message, emoji, info.mine)}> + + + {emoji} {info.count} + + + + ))} + + ) : null} + + {message.ThreadReplyCount > 0 && onOpenThread ? ( + onOpenThread(message)}> + + + {t('chat.thread_replies', { count: message.ThreadReplyCount })} + + + ) : null} + + + {message.PinnedOn ? : null} + {message.EditedOn && !isDeleted ? {t('chat.edited')} : null} + {formatShortTime(message.SentOn)} + {isPending ? : null} + {isFailed ? ( + onRetry?.(message)}> + + + {t('chat.failed_tap_retry')} + + + ) : null} + + + + ); +} diff --git a/src/components/chat/message-composer.tsx b/src/components/chat/message-composer.tsx new file mode 100644 index 00000000..72a1e151 --- /dev/null +++ b/src/components/chat/message-composer.tsx @@ -0,0 +1,166 @@ +import * as ImagePicker from 'expo-image-picker'; +import * as Location from 'expo-location'; +import { AlertTriangle, ImagePlus, MapPin, Send, Smile, Sparkles } from 'lucide-react-native'; +import React, { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Platform } from 'react-native'; + +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; +import { logger } from '@/lib/logging'; +import { useToastStore } from '@/stores/toast/store'; + +const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '🙏', '🔥', '😮', '😢', '👏', '✅', '🚒', '🚑', '👀', '💯', '🆗', '⚠️']; + +interface MessageComposerProps { + onSendText: (body: string, urgent: boolean) => void; + onSendImage: (uri: string, urgent: boolean) => void; + onSendLocation: (latitude: number, longitude: number, urgent: boolean) => void; + onOpenGif: () => void; + onTyping: (isTyping: boolean) => void; + disabled?: boolean; + placeholder?: string; +} + +export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpenGif, onTyping, disabled, placeholder }: MessageComposerProps) { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const [urgent, setUrgent] = useState(false); + const [emojiOpen, setEmojiOpen] = useState(false); + const typingActive = useRef(false); + + const stopTyping = useCallback(() => { + if (typingActive.current) { + typingActive.current = false; + onTyping(false); + } + }, [onTyping]); + + const handleChange = useCallback( + (value: string) => { + setText(value); + if (value.length > 0) { + typingActive.current = true; + onTyping(true); + } else { + stopTyping(); + } + }, + [onTyping, stopTyping] + ); + + const handleSend = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + onSendText(trimmed, urgent); + setText(''); + setUrgent(false); + stopTyping(); + }, [text, urgent, onSendText, stopTyping]); + + const handlePickImage = useCallback(async () => { + try { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + useToastStore.getState().showToast('error', t('chat.permission_photos_denied')); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.7 }); + if (!result.canceled && result.assets[0]?.uri) { + onSendImage(result.assets[0].uri, urgent); + setUrgent(false); + } + } catch (error) { + logger.error({ message: 'chat: image pick failed', context: { error } }); + } + }, [onSendImage, urgent, t]); + + const handleShareLocation = useCallback(async () => { + try { + const permission = await Location.requestForegroundPermissionsAsync(); + if (!permission.granted) { + useToastStore.getState().showToast('error', t('chat.permission_location_denied')); + return; + } + const position = await Location.getCurrentPositionAsync({}); + onSendLocation(position.coords.latitude, position.coords.longitude, urgent); + setUrgent(false); + } catch (error) { + logger.error({ message: 'chat: location share failed', context: { error } }); + } + }, [onSendLocation, urgent, t]); + + return ( + + + setEmojiOpen(true)} accessibilityLabel={t('chat.emoji')}> + + + + + + + + + + + + + + + + + setUrgent((prev) => !prev)} accessibilityLabel={t('chat.urgent')}> + + + + + + + + + {urgent ? ( + + + {t('chat.urgent_will_send')} + + ) : null} + + setEmojiOpen(false)}> + + + + + + + {QUICK_EMOJIS.map((emoji) => ( + { + setText((prev) => prev + emoji); + setEmojiOpen(false); + }} + > + {emoji} + + ))} + + + + + ); +} diff --git a/src/components/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx new file mode 100644 index 00000000..f2f823d1 --- /dev/null +++ b/src/components/chat/new-conversation-sheet.tsx @@ -0,0 +1,189 @@ +import { Check, Search, Users } from 'lucide-react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +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 { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; +import { Center } from '@/components/ui/center'; +import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; +import { ScrollView } from '@/components/ui/scroll-view'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; +import { getAvatarUrl } from '@/lib/utils'; +import { type RecipientsResultData } from '@/models/v4/messages/recipientsResultData'; +import { useToastStore } from '@/stores/toast/store'; + +interface NewConversationSheetProps { + isOpen: boolean; + onClose: () => void; + mode: 'dm' | 'group'; + onCreated: (channelId: string) => void; +} + +/** Strips a possible "P:" / "U:" style prefix from a recipient id. */ +function recipientUserId(recipient: RecipientsResultData): string { + return recipient.Id.includes(':') ? (recipient.Id.split(':').pop() ?? recipient.Id) : recipient.Id; +} + +function isPersonRecipient(recipient: RecipientsResultData): boolean { + const type = (recipient.Type ?? '').toLowerCase(); + return type === 'personnel' || type === 'person' || type === 'user' || type === 'p' || type === ''; +} + +export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewConversationSheetProps) { + const { t } = useTranslation(); + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [query, setQuery] = useState(''); + const [groupName, setGroupName] = useState(''); + const [selected, setSelected] = useState>(new Set()); + + useEffect(() => { + if (!isOpen) return; + setSelected(new Set()); + setGroupName(''); + setQuery(''); + setLoading(true); + getRecipients(true, false) + .then((result) => setRecipients((result.Data ?? []).filter(isPersonRecipient))) + .catch((error) => logger.error({ message: 'chat: failed to load recipients', context: { error } })) + .finally(() => setLoading(false)); + }, [isOpen]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return recipients; + return recipients.filter((r) => r.Name.toLowerCase().includes(q)); + }, [recipients, query]); + + const toggle = useCallback((userId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(userId)) next.delete(userId); + else next.add(userId); + return next; + }); + }, []); + + const startDirectMessage = useCallback( + async (recipient: RecipientsResultData) => { + setSubmitting(true); + try { + const response = await createDirectMessage({ TargetUserId: recipientUserId(recipient) }); + if (response.Data?.ChatChannelId) { + onCreated(response.Data.ChatChannelId); + onClose(); + } + } catch (error) { + logger.error({ message: 'chat: create DM failed', context: { error } }); + useToastStore.getState().showToast('error', t('chat.create_conversation_failed')); + } finally { + setSubmitting(false); + } + }, + [onCreated, onClose, t] + ); + + const createGroup = useCallback(async () => { + if (!groupName.trim() || selected.size === 0) return; + setSubmitting(true); + try { + const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) }); + if (response.Data?.ChatChannelId) { + onCreated(response.Data.ChatChannelId); + onClose(); + } + } catch (error) { + logger.error({ message: 'chat: create group failed', context: { error } }); + useToastStore.getState().showToast('error', t('chat.create_conversation_failed')); + } finally { + setSubmitting(false); + } + }, [groupName, selected, onCreated, onClose, t]); + + return ( + + + + + + + + + {mode === 'dm' ? t('chat.new_direct_message') : t('chat.new_group')} + + {mode === 'group' ? ( + + + + ) : null} + + + + + + + + + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {t('chat.no_people')} +
+ ) : ( + + {filtered.map((recipient) => { + const userId = recipientUserId(recipient); + const isSelected = selected.has(userId); + return ( + (mode === 'dm' ? startDirectMessage(recipient) : toggle(userId))} + disabled={submitting} + > + + + + {recipient.Name} + + + + {recipient.Name} + + + {mode === 'group' && isSelected ? ( + + + + ) : null} + + + ); + })} + + )} + + {mode === 'group' ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/components/chat/typing-indicator.tsx b/src/components/chat/typing-indicator.tsx new file mode 100644 index 00000000..25431334 --- /dev/null +++ b/src/components/chat/typing-indicator.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Animated } from 'react-native'; + +import { HStack } from '@/components/ui/hstack'; +import { Text } from '@/components/ui/text'; + +/** Three subtly pulsing dots used by the typing / assistant-thinking rows. */ +export function TypingDots({ color = '#9ca3af' }: { color?: string }) { + const dots = useRef([new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)]).current; + + useEffect(() => { + const animations = dots.map((dot, index) => + Animated.loop( + Animated.sequence([ + Animated.delay(index * 150), + Animated.timing(dot, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 400, useNativeDriver: true }), + ]) + ) + ); + animations.forEach((animation) => animation.start()); + return () => animations.forEach((animation) => animation.stop()); + }, [dots]); + + return ( + + {dots.map((dot, index) => ( + + ))} + + ); +} + +interface TypingIndicatorProps { + names: string[]; +} + +export function TypingIndicator({ names }: TypingIndicatorProps) { + const { t } = useTranslation(); + if (names.length === 0) return null; + + const label = names.length === 1 ? t('chat.is_typing', { name: names[0] }) : t('chat.are_typing', { count: names.length }); + + return ( + + + {label} + + ); +} diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index d4480f1d..540b3b8e 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'expo-router'; -import { Settings } from 'lucide-react-native'; +import { MessagesSquare, Settings, Sparkles } from 'lucide-react-native'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; @@ -35,6 +35,16 @@ const Sidebar = ({ onClose }: SidebarProps) => { router.push('/settings'); }; + const handleNavigateToChat = () => { + onClose?.(); + router.push('/chat'); + }; + + const handleNavigateToAssistant = () => { + onClose?.(); + router.push('/chatbot'); + }; + return ( @@ -53,6 +63,18 @@ const Sidebar = ({ onClose }: SidebarProps) => { {/* Check-in timer widget */} + {/* Chat + Assistant navigation */} + + + + + {/* Third row - Status buttons or empty state */} {isActiveStatusesEmpty ? ( { if (result.status === 'rejected') { - const hubName = index === 0 ? 'UpdateHub' : 'GeolocationHub'; + const hubName = hubNames[index] ?? 'Hub'; logger.error({ message: `Failed to disconnect ${hubName} on app background`, context: { error: result.reason }, @@ -120,12 +121,13 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi // Use getState() to access store actions without subscribing to store changes const store = useSignalRStore.getState(); // Use Promise.allSettled to prevent one failure from blocking the other - const results = await Promise.allSettled([store.connectUpdateHub(), store.connectGeolocationHub()]); + const hubNames = ['UpdateHub', 'GeolocationHub', 'ChatHub']; + const results = await Promise.allSettled([store.connectUpdateHub(), store.connectGeolocationHub(), store.connectChatHub()]); // Log any failures without throwing results.forEach((result, index) => { if (result.status === 'rejected') { - const hubName = index === 0 ? 'UpdateHub' : 'GeolocationHub'; + const hubName = hubNames[index] ?? 'Hub'; logger.error({ message: `Failed to reconnect ${hubName} on app resume`, context: { error: result.reason }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 40b275df..a9b66c39 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,8 @@ import { Linking } from 'react-native'; import { Platform } from 'react-native'; import type { StoreApi, UseBoundStore } from 'zustand'; +import { getBaseApiUrl } from './storage/app'; + /** * Parses an API UTC timestamp. Server "*Utc" fields arrive WITHOUT a timezone * designator (e.g. "2025-08-06T17:30:00") and `new Date()` would parse those as @@ -101,6 +103,11 @@ export function uuidv4() { }); } +/** Absolute URL for a person's avatar image, served by the Resgrid API. */ +export function getAvatarUrl(userId: string) { + return getBaseApiUrl() + '/Avatars/Get?id=' + userId; +} + /** * Strips HTML tags and elements from a text string * @param html The HTML string to be stripped diff --git a/src/models/v4/chat/chatEnums.ts b/src/models/v4/chat/chatEnums.ts new file mode 100644 index 00000000..006fd1c9 --- /dev/null +++ b/src/models/v4/chat/chatEnums.ts @@ -0,0 +1,69 @@ +/** + * Chat domain enums. Values mirror the Resgrid v4 Chat API contract exactly. + */ + +/** Channel type (ChatChannelResultData.ChannelType). */ +export enum ChatChannelType { + DirectMessage = 0, + AdHocGroup = 1, + DepartmentDefault = 2, + GroupDefault = 3, + CustomLocked = 4, + Incident = 5, + IncidentLane = 6, + IncidentCommand = 7, + Chatbot = 8, +} + +/** Message type (ChatMessageResultData.MessageType). */ +export enum ChatMessageType { + Text = 0, + Image = 1, + Gif = 2, + Location = 3, + System = 4, + Bot = 5, +} + +/** Message priority (ChatMessageResultData.Priority). */ +export enum ChatMessagePriority { + Normal = 0, + Urgent = 1, +} + +/** Participant type for senders / members / reactors. */ +export enum ChatParticipantType { + User = 0, + Unit = 1, + Bot = 2, +} + +/** Per-channel notification preference. */ +export enum ChatNotificationPreference { + Default = 0, + All = 1, + MentionsOnly = 2, + Muted = 3, +} + +/** Mention type (ChatMentionInput.MentionType). */ +export enum ChatMentionType { + User = 0, + Unit = 1, + Role = 2, + Group = 3, + Everyone = 4, +} + +/** Reason for flagging a message for moderator review. */ +export enum ChatFlagReason { + Other = 0, + Inappropriate = 1, + Harassment = 2, + Spam = 3, + SensitiveInformation = 4, + PolicyViolation = 5, +} + +/** Client-only lifecycle status for optimistic messages (never sent by the server). */ +export type ChatMessageLocalStatus = 'pending' | 'failed' | 'sent'; diff --git a/src/models/v4/chat/chatEvents.ts b/src/models/v4/chat/chatEvents.ts new file mode 100644 index 00000000..a9568223 --- /dev/null +++ b/src/models/v4/chat/chatEvents.ts @@ -0,0 +1,59 @@ +import { type ChatReactionResultData } from './chatModels'; + +/** + * SignalR client-event payload shapes for the chat hub. Most events arrive as a + * JSON string that parses to one of these; chatTyping and chatPresenceChanged + * arrive as already-deserialized objects. Handlers parse defensively, so these + * types document the expected shape rather than a strict contract. + */ + +export interface ChatMessageDeletedEvent { + ChatMessageId: string; + ChatChannelId: string; + DeletedOn?: string; + DeletedByUserId?: string; +} + +export interface ChatReactionUpdatedEvent { + ChatMessageId: string; + ChatChannelId: string; + Reactions: ChatReactionResultData[]; +} + +export interface ChatReceiptUpdatedEvent { + ChatChannelId: string; + UserId?: string; + UnitId?: number; + Seq: number; +} + +export interface ChatThreadUpdatedEvent { + ChatChannelId: string; + ThreadRootMessageId: string; + ThreadReplyCount: number; + LastThreadReplyOn?: string; +} + +export interface ChatModerationAppliedEvent { + ChatChannelId: string; + ChatMessageId?: string; + ActionType: number; + Reason?: string; +} + +export interface ChatTypingEvent { + ChatChannelId: string; + UserId?: string; + DisplayName?: string; + IsTyping: boolean; +} + +export interface ChatPresenceChangedEvent { + UserId: string; + IsOnline: boolean; +} + +export interface ChatbotTypingEvent { + ChatChannelId: string; + IsTyping: boolean; +} diff --git a/src/models/v4/chat/chatInputs.ts b/src/models/v4/chat/chatInputs.ts new file mode 100644 index 00000000..62f88435 --- /dev/null +++ b/src/models/v4/chat/chatInputs.ts @@ -0,0 +1,67 @@ +/** + * Request body (input) DTOs for the Resgrid v4 Chat API. Field names mirror + * ChatApiModels.cs exactly. + */ + +export interface CreateDirectMessageInput { + TargetUserId?: string; + TargetUnitId?: number; +} + +export interface CreateAdHocChannelInput { + Name: string; + MemberUserIds: string[]; +} + +export interface UpdateChannelInput { + Name: string; + Topic: string; +} + +export interface AddMembersInput { + UserIds: string[]; +} + +export interface SetNotificationPreferenceInput { + Preference: number; +} + +/** An @mention inside a chat message. */ +export interface ChatMentionInput { + MentionType: number; + TargetUserId?: string; + TargetUnitId?: number; + TargetRoleId?: number; + TargetGroupId?: number; +} + +export interface SendChatMessageInput { + ClientMessageId: string; + Body: string; + MessageType: number; + Priority: number; + AsUnitId?: number; + AsIncidentCommander?: boolean; + ThreadRootMessageId?: string; + AlsoSendToChannel?: boolean; + MetadataJson?: string; + Mentions?: ChatMentionInput[]; +} + +export interface EditMessageInput { + Body: string; +} + +export interface AddReactionInput { + Emoji: string; +} + +export interface MarkReadInput { + Seq: number; + AsUnitId?: number; +} + +export interface FlagMessageInput { + Reason: number; + Note?: string; +} diff --git a/src/models/v4/chat/chatModels.ts b/src/models/v4/chat/chatModels.ts new file mode 100644 index 00000000..c2487fd7 --- /dev/null +++ b/src/models/v4/chat/chatModels.ts @@ -0,0 +1,166 @@ +import { type ChatMessageLocalStatus } from './chatEnums'; + +/** + * Result data types for the Resgrid v4 Chat API. Field names mirror + * ChatApiModels.cs exactly. Nullable server fields are modelled as optional. + */ + +/** Standard v4 API envelope: most Chat endpoints return { Data, ...metadata }. */ +export interface ChatV4Response { + Data: TData; + PageSize?: number; + Status?: string; + Timestamp?: string; + Version?: string; + Node?: string; + RequestId?: string; + Environment?: string; +} + +/** Envelope for simple write operations that return a Success flag. */ +export interface ChatActionResult { + Success: boolean; + Status?: string; +} + +/** Envelope returned by UploadAttachment. */ +export interface ChatAttachmentUploadedResult { + ChatAttachmentId: string; + Status?: string; +} + +/** Envelope returned by GetPresence. */ +export interface GetChatPresenceResult { + OnlineUserIds: string[]; + Status?: string; +} + +/** An emoji reaction on a chat message. */ +export interface ChatReactionResultData { + Emoji: string; + ParticipantType: number; + UserId?: string | null; + UnitId?: number | null; +} + +/** Attachment metadata (the binary is downloaded separately). */ +export interface ChatAttachmentResultData { + ChatAttachmentId: string; + FileName: string; + ContentType: string; + Size: number; +} + +/** Chat channel data. */ +export interface ChatChannelResultData { + ChatChannelId: string; + ChannelType: number; + Name: string; + Topic?: string | null; + GroupId?: number | null; + CallId?: number | null; + CommandStructureNodeId?: string | null; + OwnerUserId?: string | null; + IsArchived: boolean; + IsLocked: boolean; + LastMessageSeq: number; + LastMessageOn?: string | null; + CreatedOn: string; + UnreadCount: number; + NotificationPreference: number; + MyLastReadSeq: number; +} + +/** Chat message data. */ +export interface ChatMessageResultData { + ChatMessageId: string; + ChatChannelId: string; + DepartmentId?: number; + MessageSeq: number; + SenderParticipantType: number; + SenderUserId?: string | null; + SenderUnitId?: number | null; + SenderDisplayName?: string | null; + Body?: string | null; + MessageType: number; + Priority: number; + ThreadRootMessageId?: string | null; + ThreadReplyCount: number; + LastThreadReplyOn?: string | null; + AlsoSendToChannel: boolean; + MetadataJson?: string | null; + ClientMessageId?: string | null; + SentOn: string; + EditedOn?: string | null; + DeletedOn?: string | null; + DeletedByUserId?: string | null; + PinnedOn?: string | null; + PinnedByUserId?: string | null; + Reactions: ChatReactionResultData[]; + Attachments: ChatAttachmentResultData[]; + /** Client-only optimistic-send status. Not part of the API contract. */ + _localStatus?: ChatMessageLocalStatus; + /** Client-only local image/file uri for optimistic attachment rendering. */ + _localAttachmentUri?: string; +} + +/** A chat channel member's state. */ +export interface ChatMemberResultData { + ChatChannelMemberId: string; + ChatChannelId: string; + ParticipantType: number; + UserId?: string | null; + UnitId?: number | null; + DisplayNameOverride?: string | null; + IsModerator: boolean; + JoinedOn: string; + RemovedOn?: string | null; + LastReadSeq: number; + LastReadOn?: string | null; + LastDeliveredSeq: number; + MutedUntil?: string | null; + IsBanned: boolean; + NotificationPreference: number; +} + +/** An acknowledgment row for an urgent chat message. */ +export interface ChatAckResultData { + ChatMessageAckId: string; + ChatMessageId: string; + ChatChannelId: string; + UserId?: string | null; + UnitId?: number | null; + RequiredOn: string; + AcknowledgedOn?: string | null; +} + +/** A GIF search hit from the configured provider. */ +export interface GifResultData { + Id: string; + Title?: string | null; + PreviewUrl: string; + GifUrl: string; + Width: number; + Height: number; +} + +/** Metadata payloads embedded in ChatMessageResultData.MetadataJson. */ +export interface ChatLocationMetadata { + Latitude: number; + Longitude: number; + Label?: string; +} + +export interface ChatGifMetadata { + GifUrl: string; + PreviewUrl?: string; + Width?: number; + Height?: number; + Title?: string; +} + +export interface ChatImageMetadata { + AttachmentId?: string; + Width?: number; + Height?: number; +} diff --git a/src/models/v4/chat/chatbotModels.ts b/src/models/v4/chat/chatbotModels.ts new file mode 100644 index 00000000..4f664c22 --- /dev/null +++ b/src/models/v4/chat/chatbotModels.ts @@ -0,0 +1,21 @@ +/** + * Chatbot (assistant) API response shapes. Unlike the Chat controller, the + * Chatbot web-chat endpoints return plain objects (not the { Data } envelope). + */ + +export interface ChatbotChannelResponse { + ChatChannelId: string; + Name?: string | null; + LastMessageSeq: number; + LastMessageOn?: string | null; +} + +export interface ChatbotSendResponse { + ChatMessageId: string; + MessageSeq: number; + SentOn: string; +} + +export interface ChatbotSessionResponse { + success: boolean; +} diff --git a/src/models/v4/chat/index.ts b/src/models/v4/chat/index.ts new file mode 100644 index 00000000..c92445a2 --- /dev/null +++ b/src/models/v4/chat/index.ts @@ -0,0 +1,6 @@ +export * from './chatEnums'; +export * from './chatModels'; +export * from './chatInputs'; +export * from './chatEvents'; +export * from './chatbotModels'; +export * from './outbox'; diff --git a/src/models/v4/chat/outbox.ts b/src/models/v4/chat/outbox.ts new file mode 100644 index 00000000..c567d098 --- /dev/null +++ b/src/models/v4/chat/outbox.ts @@ -0,0 +1,26 @@ +import { type ChatMentionInput } from './chatInputs'; + +/** + * A pending outbound message persisted to MMKV so unsent messages survive an + * app restart / reconnect. Keyed by a client-generated ClientMessageId which + * the server treats as an idempotency key, so draining the outbox is a safe + * resend. + */ +export interface ChatOutboxItem { + ClientMessageId: string; + ChannelId: string; + Body: string; + MessageType: number; + Priority: number; + AsUnitId?: number; + AsIncidentCommander?: boolean; + ThreadRootMessageId?: string; + AlsoSendToChannel?: boolean; + MetadataJson?: string; + Mentions?: ChatMentionInput[]; + /** Snapshot of the sender display name for optimistic rendering. */ + SenderDisplayName?: string; + /** Sender user id for optimistic rendering. */ + SenderUserId?: string; + CreatedAt: number; +} diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index ccb73bf2..6aa4b66a 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -1,6 +1,7 @@ import notifee, { AndroidImportance, AndroidVisibility, AuthorizationStatus, EventType } from '@notifee/react-native'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; +import { router } from 'expo-router'; import { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; @@ -32,6 +33,24 @@ export interface PushNotificationData { data?: Record; } +/** + * Handles chat push deep-links. Chat notifications carry an eventCode of + * "t:{channelId}" (direct message) or "g:{channelId}" (group/channel); both + * navigate to the chat conversation route. + */ +export function handleChatDeepLink(eventCode: string): boolean { + const match = /^([tg]):(.+)$/.exec(eventCode); + if (!match) return false; + const channelId = match[2]; + try { + router.push(`/chat/${channelId}`); + return true; + } catch (error) { + logger.error({ message: 'Failed to deep-link to chat channel', context: { error, eventCode } }); + return false; + } +} + // Configure how notifications are presented while the app is in the foreground. // With expo-notifications owning the UNUserNotificationCenter delegate (Firebase // no longer does), this controls the native banner/sound/badge presentation. @@ -258,6 +277,11 @@ class PushNotificationService { // Delay so the React tree is mounted and the modal store is ready. setTimeout(() => { + // Deep-link chat notifications: eventCode "t:{channelId}" (DM) / "g:{channelId}" (group). + const eventCode = data?.eventCode; + if (typeof eventCode === 'string' && handleChatDeepLink(eventCode)) { + return; + } this.showModalForData(data, content.title, content.body); }, delayMs); } diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 395801fa..5fdda979 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -592,7 +592,7 @@ class SignalRService { } } - public async invoke(hubName: string, method: string, data: unknown): Promise { + public async invoke(hubName: string, method: string, ...args: unknown[]): Promise { // Wait for any ongoing connection attempt to complete const existingLock = this.connectionLocks.get(hubName); if (existingLock) { @@ -606,7 +606,7 @@ class SignalRService { const connection = this.connections.get(hubName); if (connection) { try { - return await connection.invoke(method, data); + return await connection.invoke(method, ...args); } catch (error) { logger.error({ message: `Error invoking method ${method} from hub: ${hubName}`, diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts new file mode 100644 index 00000000..a27b382f --- /dev/null +++ b/src/stores/chat/store.ts @@ -0,0 +1,886 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import * as chatApi from '@/api/chat/chat'; +import * as chatbotApi from '@/api/chat/chatbot'; +import { Env } from '@/lib/env'; +import { logger } from '@/lib/logging'; +import { zustandStorage } from '@/lib/storage'; +import { uuidv4 } from '@/lib/utils'; +import { + type ChatAckResultData, + type ChatChannelResultData, + ChatMessagePriority, + type ChatMessageResultData, + ChatMessageType, + type ChatMemberResultData, + type ChatMentionInput, + type ChatOutboxItem, + type ChatReactionResultData, +} from '@/models/v4/chat'; +import { signalRService } from '@/services/signalr.service'; +import { useCoreStore } from '@/stores/app/core-store'; +import useAuthStore from '@/stores/auth/store'; + +/** Messages at or above this sequence are optimistic (unconfirmed) sends. */ +const PENDING_SEQ_BASE = 9_000_000_000_000; +const TYPING_EXPIRY_MS = 5000; +const TYPING_THROTTLE_MS = 3000; + +export interface ChatTypingUser { + userId: string; + displayName?: string; + expiresAt: number; +} + +interface SendMessageArgs { + channelId: string; + body: string; + messageType?: number; + priority?: number; + asUnitId?: number; + asIncidentCommander?: boolean; + threadRootMessageId?: string; + alsoSendToChannel?: boolean; + metadataJson?: string; + mentions?: ChatMentionInput[]; + localAttachmentUri?: string; +} + +interface ChatState { + channels: ChatChannelResultData[]; + messagesByChannel: Record; + membersByChannel: Record; + typingByChannel: Record; + presence: Set; + pendingAcks: ChatAckResultData[]; + activeChannelId: string | null; + chatbotChannelId: string | null; + chatbotTyping: boolean; + + hasMoreByChannel: Record; + isLoadingChannels: boolean; + loadingMessagesByChannel: Record; + + /** Persisted queue of unsent messages (idempotent resend keyed by ClientMessageId). */ + outbox: ChatOutboxItem[]; + + // --- Channels --------------------------------------------------------- + fetchChannels: (activeUnitId?: number) => Promise; + setActiveChannel: (channelId: string | null) => void; + + // --- Messages --------------------------------------------------------- + loadInitialMessages: (channelId: string) => Promise; + loadOlderMessages: (channelId: string) => Promise; + loadNewerMessages: (channelId: string) => Promise; + sendMessage: (args: SendMessageArgs) => Promise; + retryOutboxItem: (clientMessageId: string) => Promise; + drainOutbox: () => Promise; + editMessage: (messageId: string, channelId: string, body: string) => Promise; + deleteMessage: (messageId: string, channelId: string) => Promise; + moderatorDeleteMessage: (messageId: string, channelId: string, reason: string) => Promise; + + // --- Reactions / acks / read / pins / flags --------------------------- + addReaction: (messageId: string, channelId: string, emoji: string) => Promise; + removeReaction: (messageId: string, channelId: string, emoji: string) => Promise; + acknowledgeMessage: (messageId: string) => Promise; + fetchPendingAcks: () => Promise; + markChannelRead: (channelId: string) => Promise; + togglePin: (messageId: string, channelId: string, pinned: boolean) => Promise; + flagMessage: (messageId: string, reason: number, note?: string) => Promise; + fetchMembers: (channelId: string) => Promise; + + // --- Chatbot (assistant) --------------------------------------------- + initChatbot: () => Promise; + sendChatbotMessage: (text: string) => Promise; + newChatbotSession: () => Promise; + + // --- Realtime send helpers (SignalR invokes) -------------------------- + joinChannel: (channelId: string) => Promise; + sendTyping: (channelId: string, isTyping: boolean) => void; + + // --- SignalR event handlers (called from the signalr store) ----------- + handleMessageReceived: (raw: unknown) => void; + handleMessageEdited: (raw: unknown) => void; + handleMessageDeleted: (raw: unknown) => void; + handleReactionUpdated: (raw: unknown) => void; + handleReceiptUpdated: (raw: unknown) => void; + handleChannelUpdated: (raw: unknown) => void; + handleChannelProvisioned: (raw: unknown) => void; + handleModerationApplied: (raw: unknown) => void; + handleAckRequired: (raw: unknown) => void; + handleThreadUpdated: (raw: unknown) => void; + handleChatbotMessageReceived: (raw: unknown) => void; + handleChatbotTyping: (raw: unknown) => void; + handleTyping: (raw: unknown) => void; + handlePresenceChanged: (raw: unknown) => void; + handleChatConnected: () => void; + + reset: () => void; +} + +// --------------------------------------------------------------------------- +// Module-scope helpers +// --------------------------------------------------------------------------- + +const typingTimers = new Map>(); +const lastTypingSentAt = new Map(); +const lastMarkedSeq = new Map(); + +function currentUserId(): string | null { + return useAuthStore.getState().userId; +} + +/** + * Unit-app chat identity: the active unit *is* the chat identity ("Engine 6"). + * The core store keeps activeUnitId as a string; the Chat API expects a numeric + * AsUnitId. Returns undefined when no unit is selected so the caller falls back + * to the signed-in user's identity. + */ +function activeUnitIdNumber(): number | undefined { + const id = useCoreStore.getState().activeUnitId; + if (!id) return undefined; + const parsed = Number(id); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseEventData(raw: unknown): T | null { + if (raw == null) return null; + if (typeof raw === 'string') { + try { + return JSON.parse(raw) as T; + } catch (error) { + logger.warn({ message: 'chat: failed to parse event JSON', context: { error } }); + return null; + } + } + if (typeof raw === 'object') return raw as T; + return null; +} + +function isPending(message: ChatMessageResultData): boolean { + return message.MessageSeq >= PENDING_SEQ_BASE; +} + +function compareMessages(a: ChatMessageResultData, b: ChatMessageResultData): number { + if (a.MessageSeq !== b.MessageSeq) return a.MessageSeq - b.MessageSeq; + return new Date(a.SentOn).getTime() - new Date(b.SentOn).getTime(); +} + +/** 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[] { + const next = list.slice(); + 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 }; + } else { + next.push(incoming); + } + next.sort(compareMessages); + return next; +} + +function highestRealSeq(list: ChatMessageResultData[] | undefined): number { + if (!list || list.length === 0) return 0; + let max = 0; + for (const m of list) { + if (!isPending(m) && m.MessageSeq > max) max = m.MessageSeq; + } + return max; +} + +function lowestRealSeq(list: ChatMessageResultData[] | undefined): number | undefined { + if (!list || list.length === 0) return undefined; + let min: number | undefined; + for (const m of list) { + if (!isPending(m) && (min === undefined || m.MessageSeq < min)) min = m.MessageSeq; + } + return min; +} + +async function safeInvoke(method: string, ...args: unknown[]): Promise { + try { + await signalRService.invoke(Env.CHAT_HUB_NAME, method, ...args); + } catch (error) { + logger.debug({ message: `chat: invoke ${method} skipped`, context: { error } }); + } +} + +export const useChatStore = create()( + persist( + (set, get) => ({ + channels: [], + messagesByChannel: {}, + membersByChannel: {}, + typingByChannel: {}, + presence: new Set(), + pendingAcks: [], + activeChannelId: null, + chatbotChannelId: null, + chatbotTyping: false, + hasMoreByChannel: {}, + isLoadingChannels: false, + loadingMessagesByChannel: {}, + outbox: [], + + // ------------------------------------------------------------------ + // Channels + // ------------------------------------------------------------------ + fetchChannels: async (activeUnitId?: number) => { + // The Unit app scopes the channel list to the active unit ("Engine 6"). + const unitId = activeUnitId ?? activeUnitIdNumber(); + set({ isLoadingChannels: true }); + try { + const response = await chatApi.getChannels(unitId); + set({ channels: response.Data ?? [], isLoadingChannels: false }); + } catch (error) { + logger.error({ message: 'chat: failed to fetch channels', context: { error } }); + set({ isLoadingChannels: false }); + } + }, + + setActiveChannel: (channelId: string | null) => { + set({ activeChannelId: channelId }); + }, + + // ------------------------------------------------------------------ + // Messages + // ------------------------------------------------------------------ + loadInitialMessages: async (channelId: string) => { + set((state) => ({ loadingMessagesByChannel: { ...state.loadingMessagesByChannel, [channelId]: true } })); + try { + const response = await chatApi.getMessages(channelId, undefined, 50); + const incoming = response.Data ?? []; + set((state) => { + let list = state.messagesByChannel[channelId] ?? []; + for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' }); + return { + messagesByChannel: { ...state.messagesByChannel, [channelId]: list }, + hasMoreByChannel: { ...state.hasMoreByChannel, [channelId]: incoming.length >= 50 }, + loadingMessagesByChannel: { ...state.loadingMessagesByChannel, [channelId]: false }, + }; + }); + } catch (error) { + logger.error({ message: 'chat: failed to load messages', context: { error, channelId } }); + set((state) => ({ loadingMessagesByChannel: { ...state.loadingMessagesByChannel, [channelId]: false } })); + } + }, + + loadOlderMessages: async (channelId: string) => { + const state = get(); + if (state.loadingMessagesByChannel[channelId]) return; + if (state.hasMoreByChannel[channelId] === false) return; + const before = lowestRealSeq(state.messagesByChannel[channelId]); + if (before === undefined) { + await get().loadInitialMessages(channelId); + return; + } + set((s) => ({ loadingMessagesByChannel: { ...s.loadingMessagesByChannel, [channelId]: true } })); + try { + const response = await chatApi.getMessages(channelId, before, 50); + const incoming = response.Data ?? []; + set((s) => { + let list = s.messagesByChannel[channelId] ?? []; + for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' }); + return { + messagesByChannel: { ...s.messagesByChannel, [channelId]: list }, + hasMoreByChannel: { ...s.hasMoreByChannel, [channelId]: incoming.length >= 50 }, + loadingMessagesByChannel: { ...s.loadingMessagesByChannel, [channelId]: false }, + }; + }); + } catch (error) { + logger.error({ message: 'chat: failed to load older messages', context: { error, channelId } }); + set((s) => ({ loadingMessagesByChannel: { ...s.loadingMessagesByChannel, [channelId]: false } })); + } + }, + + loadNewerMessages: async (channelId: string) => { + const after = highestRealSeq(get().messagesByChannel[channelId]); + try { + const response = await chatApi.getMessagesAfter(channelId, after, 200); + const incoming = response.Data ?? []; + if (incoming.length === 0) return; + set((s) => { + let list = s.messagesByChannel[channelId] ?? []; + for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' }); + return { messagesByChannel: { ...s.messagesByChannel, [channelId]: list } }; + }); + } catch (error) { + logger.error({ message: 'chat: delta sync failed', context: { error, channelId } }); + } + }, + + sendMessage: async (args: SendMessageArgs) => { + const clientMessageId = uuidv4(); + const now = new Date().toISOString(); + const userId = currentUserId(); + // Unit app posts as the active unit ("Engine 6") unless a caller overrides. + const asUnitId = args.asUnitId ?? activeUnitIdNumber(); + + const outboxItem: ChatOutboxItem = { + ClientMessageId: clientMessageId, + ChannelId: args.channelId, + Body: args.body, + MessageType: args.messageType ?? ChatMessageType.Text, + Priority: args.priority ?? ChatMessagePriority.Normal, + AsUnitId: asUnitId, + AsIncidentCommander: args.asIncidentCommander, + ThreadRootMessageId: args.threadRootMessageId, + AlsoSendToChannel: args.alsoSendToChannel, + MetadataJson: args.metadataJson, + Mentions: args.mentions, + SenderUserId: userId ?? undefined, + CreatedAt: Date.now(), + }; + + const optimistic: ChatMessageResultData = { + ChatMessageId: `local-${clientMessageId}`, + ChatChannelId: args.channelId, + MessageSeq: PENDING_SEQ_BASE + outboxItem.CreatedAt, + SenderParticipantType: 0, + SenderUserId: userId ?? undefined, + SenderDisplayName: '', + Body: args.body, + MessageType: outboxItem.MessageType, + Priority: outboxItem.Priority, + ThreadRootMessageId: args.threadRootMessageId ?? null, + ThreadReplyCount: 0, + AlsoSendToChannel: args.alsoSendToChannel ?? false, + MetadataJson: args.metadataJson ?? null, + ClientMessageId: clientMessageId, + SentOn: now, + Reactions: [], + Attachments: [], + _localStatus: 'pending', + _localAttachmentUri: args.localAttachmentUri, + }; + + set((state) => ({ + outbox: [...state.outbox, outboxItem], + messagesByChannel: { ...state.messagesByChannel, [args.channelId]: upsertMessage(state.messagesByChannel[args.channelId] ?? [], optimistic) }, + })); + + await sendOutboxItem(outboxItem, set, get); + }, + + retryOutboxItem: async (clientMessageId: string) => { + const item = get().outbox.find((o) => o.ClientMessageId === clientMessageId); + if (!item) return; + await sendOutboxItem(item, set, get); + }, + + drainOutbox: async () => { + const items = [...get().outbox]; + for (const item of items) { + await sendOutboxItem(item, set, get); + } + }, + + editMessage: async (messageId: string, channelId: string, body: string) => { + try { + const response = await chatApi.editMessage(messageId, { Body: body }); + if (response.Data) { + set((s) => ({ messagesByChannel: { ...s.messagesByChannel, [channelId]: upsertMessage(s.messagesByChannel[channelId] ?? [], { ...response.Data, _localStatus: 'sent' }) } })); + } + } catch (error) { + logger.error({ message: 'chat: edit failed', context: { error, messageId } }); + } + }, + + deleteMessage: async (messageId: string, channelId: string) => { + try { + await chatApi.deleteMessage(messageId); + patchMessage(set, channelId, messageId, { DeletedOn: new Date().toISOString(), DeletedByUserId: currentUserId() ?? undefined }); + } catch (error) { + logger.error({ message: 'chat: delete failed', context: { error, messageId } }); + } + }, + + moderatorDeleteMessage: async (messageId: string, channelId: string, reason: string) => { + try { + await chatApi.moderatorDeleteMessage(messageId, reason); + patchMessage(set, channelId, messageId, { DeletedOn: new Date().toISOString() }); + } catch (error) { + logger.error({ message: 'chat: moderator delete failed', context: { error, messageId } }); + } + }, + + // ------------------------------------------------------------------ + // Reactions / acks / read / pins / flags + // ------------------------------------------------------------------ + addReaction: async (messageId: string, channelId: string, emoji: string) => { + const userId = currentUserId(); + // optimistic + set((s) => { + const list = s.messagesByChannel[channelId] ?? []; + const idx = list.findIndex((m) => m.ChatMessageId === messageId); + if (idx < 0) return {}; + const msg = list[idx]; + if (msg.Reactions.some((r) => r.Emoji === emoji && r.UserId === userId)) return {}; + const reactions: ChatReactionResultData[] = [...msg.Reactions, { Emoji: emoji, ParticipantType: 0, UserId: userId }]; + const next = list.slice(); + next[idx] = { ...msg, Reactions: reactions }; + return { messagesByChannel: { ...s.messagesByChannel, [channelId]: next } }; + }); + try { + await chatApi.addReaction(messageId, { Emoji: emoji }); + } catch (error) { + logger.error({ message: 'chat: add reaction failed', context: { error, messageId } }); + } + }, + + removeReaction: async (messageId: string, channelId: string, emoji: string) => { + const userId = currentUserId(); + set((s) => { + const list = s.messagesByChannel[channelId] ?? []; + const idx = list.findIndex((m) => m.ChatMessageId === messageId); + if (idx < 0) return {}; + const msg = list[idx]; + const reactions = msg.Reactions.filter((r) => !(r.Emoji === emoji && r.UserId === userId)); + const next = list.slice(); + next[idx] = { ...msg, Reactions: reactions }; + return { messagesByChannel: { ...s.messagesByChannel, [channelId]: next } }; + }); + try { + await chatApi.removeReaction(messageId, emoji); + } catch (error) { + logger.error({ message: 'chat: remove reaction failed', context: { error, messageId } }); + } + }, + + acknowledgeMessage: async (messageId: string) => { + try { + await chatApi.ackMessage(messageId); + set((s) => ({ pendingAcks: s.pendingAcks.filter((a) => a.ChatMessageId !== messageId) })); + } catch (error) { + logger.error({ message: 'chat: ack failed', context: { error, messageId } }); + } + }, + + fetchPendingAcks: async () => { + try { + const response = await chatApi.getMyPendingAcks(); + set({ pendingAcks: response.Data ?? [] }); + } catch (error) { + logger.debug({ message: 'chat: fetch pending acks failed', context: { error } }); + } + }, + + markChannelRead: async (channelId: string) => { + const seq = highestRealSeq(get().messagesByChannel[channelId]); + if (seq <= 0) return; + if ((lastMarkedSeq.get(channelId) ?? 0) >= seq) return; + lastMarkedSeq.set(channelId, seq); + + set((s) => ({ + channels: s.channels.map((c) => (c.ChatChannelId === channelId ? { ...c, UnreadCount: 0, MyLastReadSeq: seq } : c)), + })); + + // Read pointer is recorded against the active unit identity in the Unit app. + const asUnitId = activeUnitIdNumber(); + void safeInvoke('MarkRead', channelId, seq, ...(asUnitId != null ? [asUnitId] : [])); + try { + await chatApi.markRead(channelId, { Seq: seq, AsUnitId: asUnitId }); + } catch (error) { + logger.debug({ message: 'chat: markRead failed', context: { error, channelId } }); + } + }, + + togglePin: async (messageId: string, channelId: string, pinned: boolean) => { + try { + if (pinned) await chatApi.pinMessage(messageId); + else await chatApi.unpinMessage(messageId); + patchMessage(set, channelId, messageId, { PinnedOn: pinned ? new Date().toISOString() : null }); + } catch (error) { + logger.error({ message: 'chat: pin toggle failed', context: { error, messageId } }); + } + }, + + flagMessage: async (messageId: string, reason: number, note?: string) => { + try { + await chatApi.flagMessage(messageId, { Reason: reason, Note: note }); + } catch (error) { + logger.error({ message: 'chat: flag failed', context: { error, messageId } }); + } + }, + + fetchMembers: async (channelId: string) => { + try { + const response = await chatApi.getMembers(channelId); + set((s) => ({ membersByChannel: { ...s.membersByChannel, [channelId]: response.Data ?? [] } })); + } catch (error) { + logger.debug({ message: 'chat: fetch members failed', context: { error, channelId } }); + } + }, + + // ------------------------------------------------------------------ + // Chatbot (assistant) + // ------------------------------------------------------------------ + initChatbot: async () => { + try { + const channel = await chatbotApi.getChatbotChannel(); + if (channel?.ChatChannelId) { + set({ chatbotChannelId: channel.ChatChannelId }); + await get().loadInitialMessages(channel.ChatChannelId); + void get().joinChannel(channel.ChatChannelId); + } + } catch (error) { + logger.error({ message: 'chat: failed to init chatbot channel', context: { error } }); + } + }, + + sendChatbotMessage: async (text: string) => { + const channelId = get().chatbotChannelId; + if (!channelId) return; + const clientMessageId = uuidv4(); + const now = new Date().toISOString(); + const userId = currentUserId(); + + const optimistic: ChatMessageResultData = { + ChatMessageId: `local-${clientMessageId}`, + ChatChannelId: channelId, + MessageSeq: PENDING_SEQ_BASE + Date.now(), + SenderParticipantType: 0, + SenderUserId: userId ?? undefined, + SenderDisplayName: '', + Body: text, + MessageType: ChatMessageType.Text, + Priority: ChatMessagePriority.Normal, + ThreadRootMessageId: null, + ThreadReplyCount: 0, + AlsoSendToChannel: false, + MetadataJson: null, + ClientMessageId: clientMessageId, + SentOn: now, + Reactions: [], + Attachments: [], + _localStatus: 'pending', + }; + + set((s) => ({ + chatbotTyping: true, + messagesByChannel: { ...s.messagesByChannel, [channelId]: upsertMessage(s.messagesByChannel[channelId] ?? [], optimistic) }, + })); + + try { + await chatbotApi.sendChatbotMessage(text, clientMessageId); + patchMessage(set, channelId, optimistic.ChatMessageId, { _localStatus: 'sent' }); + } catch (error) { + logger.error({ message: 'chat: chatbot send failed', context: { error } }); + markOutboxFailed(set, channelId, clientMessageId); + set({ chatbotTyping: false }); + } + }, + + newChatbotSession: async () => { + try { + await chatbotApi.newChatbotSession(); + } catch (error) { + logger.error({ message: 'chat: new chatbot session failed', context: { error } }); + } + }, + + // ------------------------------------------------------------------ + // Realtime send helpers + // ------------------------------------------------------------------ + joinChannel: async (channelId: string) => { + // Join as the active unit so hub-side membership resolves to the unit. + const asUnitId = activeUnitIdNumber(); + await safeInvoke('JoinChannel', channelId, ...(asUnitId != null ? [asUnitId] : [])); + }, + + sendTyping: (channelId: string, isTyping: boolean) => { + const now = Date.now(); + if (isTyping) { + const last = lastTypingSentAt.get(channelId) ?? 0; + if (now - last < TYPING_THROTTLE_MS) return; + lastTypingSentAt.set(channelId, now); + } else { + lastTypingSentAt.delete(channelId); + } + const asUnitId = activeUnitIdNumber(); + void safeInvoke('Typing', channelId, isTyping, ...(asUnitId != null ? [asUnitId] : [])); + }, + + // ------------------------------------------------------------------ + // SignalR event handlers + // ------------------------------------------------------------------ + handleMessageReceived: (raw: unknown) => { + const msg = parseEventData(raw); + if (!msg || !msg.ChatChannelId) return; + const state = get(); + const isOwn = !!msg.SenderUserId && msg.SenderUserId === currentUserId(); + const isActive = state.activeChannelId === msg.ChatChannelId; + + set((s) => { + const list = upsertMessage(s.messagesByChannel[msg.ChatChannelId] ?? [], { ...msg, _localStatus: 'sent' }); + const channels = s.channels.map((c) => + c.ChatChannelId === msg.ChatChannelId + ? { ...c, LastMessageSeq: Math.max(c.LastMessageSeq, msg.MessageSeq), LastMessageOn: msg.SentOn, UnreadCount: isActive || isOwn ? c.UnreadCount : c.UnreadCount + 1 } + : c + ); + return { messagesByChannel: { ...s.messagesByChannel, [msg.ChatChannelId]: list }, channels }; + }); + + // Clear the sender's typing indicator now that a message landed. + removeTyping(set, msg.ChatChannelId, msg.SenderUserId ?? undefined); + }, + + handleMessageEdited: (raw: unknown) => { + const msg = parseEventData(raw); + if (!msg || !msg.ChatChannelId) return; + set((s) => ({ messagesByChannel: { ...s.messagesByChannel, [msg.ChatChannelId]: upsertMessage(s.messagesByChannel[msg.ChatChannelId] ?? [], { ...msg, _localStatus: 'sent' }) } })); + }, + + handleMessageDeleted: (raw: unknown) => { + const evt = parseEventData<{ ChatMessageId: string; ChatChannelId: string; DeletedOn?: string; DeletedByUserId?: string }>(raw); + if (!evt || !evt.ChatChannelId || !evt.ChatMessageId) return; + patchMessage(set, evt.ChatChannelId, evt.ChatMessageId, { DeletedOn: evt.DeletedOn ?? new Date().toISOString(), DeletedByUserId: evt.DeletedByUserId }); + }, + + handleReactionUpdated: (raw: unknown) => { + const evt = parseEventData<{ ChatMessageId: string; ChatChannelId: string; Reactions: ChatReactionResultData[] }>(raw); + if (!evt || !evt.ChatChannelId || !evt.ChatMessageId) return; + patchMessage(set, evt.ChatChannelId, evt.ChatMessageId, { Reactions: evt.Reactions ?? [] }); + }, + + handleReceiptUpdated: (raw: unknown) => { + const evt = parseEventData<{ ChatChannelId: string; UserId?: string; Seq: number }>(raw); + if (!evt || !evt.ChatChannelId) return; + set((s) => { + const members = s.membersByChannel[evt.ChatChannelId]; + if (!members) return {}; + const next = members.map((m) => (m.UserId && evt.UserId && m.UserId === evt.UserId ? { ...m, LastReadSeq: Math.max(m.LastReadSeq, evt.Seq) } : m)); + return { membersByChannel: { ...s.membersByChannel, [evt.ChatChannelId]: next } }; + }); + }, + + handleChannelUpdated: (raw: unknown) => { + const channel = parseEventData(raw); + if (!channel || !channel.ChatChannelId) return; + upsertChannel(set, channel); + }, + + handleChannelProvisioned: (raw: unknown) => { + const channel = parseEventData(raw); + if (!channel || !channel.ChatChannelId) return; + upsertChannel(set, channel); + }, + + handleModerationApplied: (raw: unknown) => { + const evt = parseEventData<{ ChatChannelId: string; ChatMessageId?: string }>(raw); + if (!evt || !evt.ChatChannelId) return; + if (evt.ChatMessageId) { + patchMessage(set, evt.ChatChannelId, evt.ChatMessageId, { DeletedOn: new Date().toISOString() }); + } + }, + + handleAckRequired: (raw: unknown) => { + const ack = parseEventData(raw); + if (!ack || !ack.ChatMessageId) return; + set((s) => (s.pendingAcks.some((a) => a.ChatMessageId === ack.ChatMessageId) ? {} : { pendingAcks: [...s.pendingAcks, ack] })); + }, + + handleThreadUpdated: (raw: unknown) => { + const evt = parseEventData<{ ChatChannelId: string; ThreadRootMessageId: string; ThreadReplyCount: number; LastThreadReplyOn?: string }>(raw); + if (!evt || !evt.ChatChannelId || !evt.ThreadRootMessageId) return; + patchMessage(set, evt.ChatChannelId, evt.ThreadRootMessageId, { ThreadReplyCount: evt.ThreadReplyCount, LastThreadReplyOn: evt.LastThreadReplyOn }); + }, + + handleChatbotMessageReceived: (raw: unknown) => { + const msg = parseEventData(raw); + if (!msg || !msg.ChatChannelId) return; + set((s) => ({ + chatbotTyping: false, + messagesByChannel: { ...s.messagesByChannel, [msg.ChatChannelId]: upsertMessage(s.messagesByChannel[msg.ChatChannelId] ?? [], { ...msg, _localStatus: 'sent' }) }, + })); + }, + + handleChatbotTyping: (raw: unknown) => { + const evt = parseEventData<{ IsTyping?: boolean; isTyping?: boolean }>(raw); + const isTyping = evt?.IsTyping ?? evt?.isTyping ?? false; + set({ chatbotTyping: isTyping }); + }, + + 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; + 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; + if (!channelId || !userId) return; + if (userId === currentUserId()) return; + + if (isTyping === false) { + removeTyping(set, channelId, userId); + return; + } + addTyping(set, channelId, { userId, displayName, expiresAt: Date.now() + TYPING_EXPIRY_MS }); + }, + + handlePresenceChanged: (raw: unknown) => { + 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; + if (!userId) return; + set((s) => { + const presence = new Set(s.presence); + if (isOnline) presence.add(userId); + else presence.delete(userId); + return { presence }; + }); + }, + + handleChatConnected: () => { + const { activeChannelId } = get(); + void get().fetchChannels(); + void get().fetchPendingAcks(); + void get().drainOutbox(); + if (activeChannelId) { + void get().joinChannel(activeChannelId); + void get().loadNewerMessages(activeChannelId); + } + }, + + reset: () => { + typingTimers.forEach((t) => clearTimeout(t)); + typingTimers.clear(); + lastTypingSentAt.clear(); + lastMarkedSeq.clear(); + set({ + channels: [], + messagesByChannel: {}, + membersByChannel: {}, + typingByChannel: {}, + presence: new Set(), + pendingAcks: [], + activeChannelId: null, + chatbotChannelId: null, + chatbotTyping: false, + hasMoreByChannel: {}, + loadingMessagesByChannel: {}, + }); + }, + }), + { + name: 'chat-outbox-storage', + storage: createJSONStorage(() => zustandStorage), + // Only the outbox is persisted; the message cache lives in memory + react-query. + partialize: (state) => ({ outbox: state.outbox }), + } + ) +); + +// --------------------------------------------------------------------------- +// Store-external helpers (need set/get access) +// --------------------------------------------------------------------------- + +type SetState = (partial: Partial | ((state: ChatState) => Partial)) => void; +type GetState = () => ChatState; + +async function sendOutboxItem(item: ChatOutboxItem, set: SetState, get: GetState): Promise { + try { + const response = await chatApi.sendMessage(item.ChannelId, { + ClientMessageId: item.ClientMessageId, + Body: item.Body, + MessageType: item.MessageType, + Priority: item.Priority, + AsUnitId: item.AsUnitId, + AsIncidentCommander: item.AsIncidentCommander, + ThreadRootMessageId: item.ThreadRootMessageId, + AlsoSendToChannel: item.AlsoSendToChannel, + MetadataJson: item.MetadataJson, + Mentions: item.Mentions, + }); + + const server = response.Data; + if (!server) { + markOutboxFailed(set, item.ChannelId, item.ClientMessageId); + return; + } + + // Reconcile the optimistic bubble with the server row (keep any local image uri). + set((s) => { + const list = s.messagesByChannel[item.ChannelId] ?? []; + const existing = list.find((m) => m.ClientMessageId === item.ClientMessageId); + const merged: ChatMessageResultData = { ...server, _localStatus: 'sent', _localAttachmentUri: existing?._localAttachmentUri }; + return { + messagesByChannel: { ...s.messagesByChannel, [item.ChannelId]: upsertMessage(list, merged) }, + outbox: s.outbox.filter((o) => o.ClientMessageId !== item.ClientMessageId), + }; + }); + } catch (error) { + logger.warn({ message: 'chat: send failed, kept in outbox', context: { error, clientMessageId: item.ClientMessageId } }); + markOutboxFailed(set, item.ChannelId, item.ClientMessageId); + } +} + +function markOutboxFailed(set: SetState, channelId: string, clientMessageId: string): void { + set((s) => { + const list = s.messagesByChannel[channelId] ?? []; + const idx = list.findIndex((m) => m.ClientMessageId === clientMessageId); + if (idx < 0) return {}; + const next = list.slice(); + next[idx] = { ...next[idx], _localStatus: 'failed' }; + return { messagesByChannel: { ...s.messagesByChannel, [channelId]: next } }; + }); +} + +function patchMessage(set: SetState, channelId: string, messageId: string, patch: Partial): void { + set((s) => { + const list = s.messagesByChannel[channelId] ?? []; + const idx = list.findIndex((m) => m.ChatMessageId === messageId); + if (idx < 0) return {}; + const next = list.slice(); + next[idx] = { ...next[idx], ...patch }; + return { messagesByChannel: { ...s.messagesByChannel, [channelId]: next } }; + }); +} + +function upsertChannel(set: SetState, channel: ChatChannelResultData): void { + set((s) => { + const idx = s.channels.findIndex((c) => c.ChatChannelId === channel.ChatChannelId); + if (idx < 0) return { channels: [...s.channels, channel] }; + const next = s.channels.slice(); + next[idx] = { ...next[idx], ...channel }; + return { channels: next }; + }); +} + +function addTyping(set: SetState, channelId: string, user: ChatTypingUser): void { + const key = `${channelId}:${user.userId}`; + const existing = typingTimers.get(key); + if (existing) clearTimeout(existing); + typingTimers.set( + key, + setTimeout(() => { + typingTimers.delete(key); + removeTyping(set, channelId, user.userId); + }, TYPING_EXPIRY_MS) + ); + + set((s) => { + const current = s.typingByChannel[channelId] ?? []; + const filtered = current.filter((u) => u.userId !== user.userId); + return { typingByChannel: { ...s.typingByChannel, [channelId]: [...filtered, user] } }; + }); +} + +function removeTyping(set: SetState, channelId: string, userId?: string): void { + if (!userId) return; + const key = `${channelId}:${userId}`; + const existing = typingTimers.get(key); + if (existing) { + clearTimeout(existing); + typingTimers.delete(key); + } + set((s) => { + const current = s.typingByChannel[channelId]; + if (!current) return {}; + return { typingByChannel: { ...s.typingByChannel, [channelId]: current.filter((u) => u.userId !== userId) } }; + }); +} diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index d439091c..18af4c1a 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -6,9 +6,51 @@ import { logger } from '@/lib/logging'; import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; +import { useChatStore } from '../chat/store'; import { securityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; +/** Client-event method names raised by the chat SignalR hub. */ +const CHAT_HUB_METHODS = [ + 'chatMessageReceived', + 'chatMessageEdited', + 'chatMessageDeleted', + 'chatReactionUpdated', + 'chatReceiptUpdated', + 'chatChannelUpdated', + 'chatChannelProvisioned', + 'chatModerationApplied', + 'chatMessageAckRequired', + 'chatThreadUpdated', + 'chatbotMessageReceived', + 'chatbotTyping', + 'chatTyping', + 'chatPresenceChanged', + 'onChatConnected', +]; + +// Track registered chat handlers for cleanup and the heartbeat timer. +const chatHubHandlers: Record void) | null> = {}; +let chatHeartbeatTimer: ReturnType | null = null; +const CHAT_HEARTBEAT_INTERVAL_MS = 45000; + +function unregisterChatHubHandlers(): void { + Object.keys(chatHubHandlers).forEach((event) => { + const handler = chatHubHandlers[event]; + if (handler) { + signalRService.off(event, handler); + chatHubHandlers[event] = null; + } + }); +} + +function stopChatHeartbeat(): void { + if (chatHeartbeatTimer) { + clearInterval(chatHeartbeatTimer); + chatHeartbeatTimer = null; + } +} + /** Minimal shape of the SignalR weather alert payload. The server sends * WeatherAlertId as the primary identifier, matching WeatherAlertResultData. */ interface WeatherAlertSignalRMessage { @@ -54,11 +96,14 @@ interface SignalRState { isGeolocationHubConnected: boolean; lastGeolocationMessage: unknown; lastGeolocationTimestamp: number; + isChatHubConnected: boolean; error: Error | null; connectUpdateHub: () => Promise; disconnectUpdateHub: () => Promise; connectGeolocationHub: () => Promise; disconnectGeolocationHub: () => Promise; + connectChatHub: () => Promise; + disconnectChatHub: () => Promise; } /** Join the department group on the update hub. Group membership is per- @@ -88,6 +133,7 @@ export const useSignalRStore = create((set, get) => ({ isGeolocationHubConnected: false, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, + isChatHubConnected: false, error: null, connectUpdateHub: async () => { try { @@ -330,4 +376,89 @@ export const useSignalRStore = create((set, get) => ({ set({ error: err }); } }, + connectChatHub: async () => { + try { + if (get().isChatHubConnected) { + return; + } + + const eventingUrl = useCoreStore.getState().config?.EventingUrl; + if (!eventingUrl) { + logger.warn({ message: 'EventingUrl not available for chat hub, skipping connection' }); + return; + } + + // Ensure any previous handlers are cleaned up before registering new ones. + unregisterChatHubHandlers(); + + await signalRService.connectToHubWithEventingUrl({ + name: Env.CHAT_HUB_NAME, + eventingUrl, + hubName: Env.CHAT_HUB_NAME, + methods: CHAT_HUB_METHODS, + }); + + const chat = useChatStore.getState(); + const handlerMap: Record void> = { + chatMessageReceived: chat.handleMessageReceived, + chatMessageEdited: chat.handleMessageEdited, + chatMessageDeleted: chat.handleMessageDeleted, + chatReactionUpdated: chat.handleReactionUpdated, + chatReceiptUpdated: chat.handleReceiptUpdated, + chatChannelUpdated: chat.handleChannelUpdated, + chatChannelProvisioned: chat.handleChannelProvisioned, + chatModerationApplied: chat.handleModerationApplied, + chatMessageAckRequired: chat.handleAckRequired, + chatThreadUpdated: chat.handleThreadUpdated, + chatbotMessageReceived: chat.handleChatbotMessageReceived, + chatbotTyping: chat.handleChatbotTyping, + chatTyping: chat.handleTyping, + chatPresenceChanged: chat.handlePresenceChanged, + }; + + Object.entries(handlerMap).forEach(([event, handler]) => { + const wrapped = (data: unknown) => handler(data); + chatHubHandlers[event] = wrapped; + signalRService.on(event, wrapped); + }); + + const onChatConnected = () => { + logger.info({ message: 'Connected to chat SignalR hub' }); + set({ isChatHubConnected: true, error: null }); + useChatStore.getState().handleChatConnected(); + }; + 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 }); + + stopChatHeartbeat(); + chatHeartbeatTimer = setInterval(() => { + signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => { + // Heartbeat is best-effort; ignore transient failures. + }); + }, CHAT_HEARTBEAT_INTERVAL_MS); + + logger.info({ message: 'Chat hub handlers registered successfully' }); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error occurred'); + logger.error({ message: 'Failed to connect to chat SignalR hub', context: { error: err } }); + set({ error: err }); + } + }, + disconnectChatHub: async () => { + try { + stopChatHeartbeat(); + unregisterChatHubHandlers(); + await signalRService.disconnectFromHub(Env.CHAT_HUB_NAME); + set({ isChatHubConnected: false }); + logger.info({ message: 'Chat hub disconnected and handlers cleaned up' }); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error occurred'); + logger.error({ message: 'Failed to disconnect from chat SignalR hub', context: { error: err } }); + set({ error: err }); + } + }, })); diff --git a/src/translations/en.json b/src/translations/en.json index 62d54c36..cf3b9de0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -314,6 +314,79 @@ "what3words_placeholder": "Enter what3words address (e.g., filled.count.soap)", "what3words_required": "Please enter a what3words address to search" }, + "chat": { + "title": "Chat", + "assistant": "Assistant", + "empty": "No conversations yet. Start a direct message or create a group.", + "section_assistant": "Assistant", + "section_direct_messages": "Direct Messages", + "section_channels": "Channels", + "section_incidents": "Incidents", + "new_direct_message": "New Direct Message", + "new_group": "New Group", + "open_assistant": "Open Assistant", + "create_conversation_failed": "Could not start the conversation", + "group_name": "Group name", + "search_people": "Search people", + "no_people": "No people found", + "create_group_with": "Create group ({{count}})", + "message_deleted": "This message was deleted", + "urgent": "Urgent", + "urgent_will_send": "This message will be sent as urgent", + "shared_location": "Shared location", + "thread_replies": "{{count}} replies", + "edited": "(edited)", + "failed_tap_retry": "Failed - tap to retry", + "type_a_message": "Type a message", + "emoji": "Emoji", + "add_image": "Add image", + "add_gif": "Add GIF", + "share_location": "Share location", + "send": "Send", + "someone": "Someone", + "chatting_as": "Chatting as {{name}}", + "is_typing": "{{name}} is typing...", + "are_typing": "{{count}} people are typing...", + "permission_photos_denied": "Photo library permission denied", + "permission_location_denied": "Location permission denied", + "search_gifs": "Search GIFs", + "no_gifs": "No GIFs found", + "flag_reason": "Why are you reporting this?", + "flag_inappropriate": "Inappropriate", + "flag_harassment": "Harassment", + "flag_spam": "Spam", + "flag_sensitive": "Sensitive information", + "flag_policy": "Policy violation", + "flag_other": "Other", + "reply_in_thread": "Reply in thread", + "copy": "Copy", + "copied": "Copied", + "copy_unavailable": "Copy is not available on this device", + "edit": "Edit", + "edit_message": "Edit message", + "save": "Save", + "delete": "Delete", + "pin": "Pin", + "unpin": "Unpin", + "flag": "Report", + "moderator_delete": "Remove (moderator)", + "moderator_removed": "Removed by moderator", + "attachment_failed": "Attachment upload failed", + "ack_required": "Acknowledgment required", + "ack_pending_one": "You have an urgent message to acknowledge", + "ack_pending_count": "You have {{count}} urgent messages to acknowledge", + "acknowledge": "Acknowledge", + "thread": "Thread", + "original_message": "Original message", + "reply_placeholder": "Reply..." + }, + "chatbot": { + "title": "Assistant", + "subtitle": "AI helper for your department", + "new_session": "New session", + "empty": "Ask the assistant anything to get started.", + "ask_placeholder": "Ask the assistant..." + }, "check_in": { "add_note": "Add note", "check_in_error": "Failed to record check-in", @@ -962,7 +1035,9 @@ "status_saved_successfully": "Status saved successfully!" }, "tabs": { + "assistant": "Assistant", "calls": "Calls", + "chat": "Chat", "contacts": "Contacts", "map": "Map", "notes": "Notes",