From fa1677d87125ea13bcb556042373ccb4ba308f7c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 3 Aug 2026 17:22:54 -0700 Subject: [PATCH 1/6] RG-T117 Documenting issue with expo-audio and Expo 56 --- docs/audio-stream-refactoring.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/audio-stream-refactoring.md b/docs/audio-stream-refactoring.md index feab356..45d1aa9 100644 --- a/docs/audio-stream-refactoring.md +++ b/docs/audio-stream-refactoring.md @@ -1,5 +1,19 @@ # Audio Stream Store Refactoring +## Expo SDK 56 migration requirement + +Before upgrading Dispatch to Expo SDK 56, upgrade `expo-audio` to the SDK 56-compatible version and replace all remaining `expo-av` audio usage with `expo-audio`. SDK 56 no longer provides the legacy Expo Modules Core header required by `expo-av` 16, so leaving `expo-av` installed can break the iOS archive build. + +Migration checklist: + +- Migrate `src/hooks/use-ptt.ts`, `src/components/calls/call-audio-modal.tsx`, `src/stores/app/audio-stream-store.ts`, and `src/services/audio.service.ts` to `createAudioPlayer`, `setAudioModeAsync`, `AudioPlayer`, and `playbackStatusUpdate`. +- Remove `expo-av` from `package.json`, the lockfile, tests/mocks, and the Expo Doctor exclusion after no imports remain. +- Keep this as an audio-only migration. Dispatch does not currently use the `expo-av` video component, so `expo-video` is not required for this change. +- Re-test remote MP3 streams on physical iOS and Android devices. This store originally moved to `expo-av` because remote streams had problems with the earlier `expo-audio` implementation. +- Also test PTT, call audio, background playback, interruptions, and Bluetooth/headset routing before release. + +Do not copy an SDK 56 implementation back into the current SDK 54 app unchanged. Dispatch's current `expo-audio` 1.1 API does not expose SDK 56 options such as `preferredForwardBufferDuration` or playback `status.error`. + ## Overview The audio stream store has been refactored to use `expo-av` instead of `expo-audio` to resolve issues with playing remote MP3 streams over the internet in the new Expo architecture. From bb6b1603d6327492f5cdc7332c211e760cf7f636 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 7 Aug 2026 17:16:46 -0700 Subject: [PATCH 2/6] RG-T117 chat feature flag --- src/api/feature-flags/feature-flags.ts | 37 +++++++++++++++ src/app/(app)/_layout.tsx | 36 ++++++++++---- src/app/(app)/chat.tsx | 12 ++++- src/app/(app)/chatbot.tsx | 15 ++++-- src/app/chat/[channelId].tsx | 13 +++-- src/app/chat/thread/[messageId].tsx | 13 +++-- src/components/sidebar/side-menu.tsx | 6 ++- src/stores/feature-flags/store.ts | 66 ++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 src/api/feature-flags/feature-flags.ts create mode 100644 src/stores/feature-flags/store.ts diff --git a/src/api/feature-flags/feature-flags.ts b/src/api/feature-flags/feature-flags.ts new file mode 100644 index 0000000..9840f04 --- /dev/null +++ b/src/api/feature-flags/feature-flags.ts @@ -0,0 +1,37 @@ +import { api } from '../common/client'; + +const FEATURE_TOGGLES = '/FeatureToggles'; + +// --------------------------------------------------------------------------- +// Feature toggle evaluation (department-scoped, any authenticated user). +// Backed by the v4 FeatureToggles API; keys live in Resgrid.Model.FeatureFlagKeys. +// --------------------------------------------------------------------------- + +export interface FeatureToggleData { + Key: string; + Enabled: boolean; + Value?: string | null; + ValueType?: string | null; + Source?: string | null; +} + +export interface FeatureTogglesResult { + Data?: FeatureToggleData[]; + StateHash?: string; +} + +export interface FeatureToggleResult { + Data?: FeatureToggleData; +} + +/** Evaluates every active flag for the caller's department. */ +export const getAllFeatureFlags = async (signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetAll`, { signal }); + return response.data; +}; + +/** Lightweight enabled-only check for a single flag. */ +export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal }); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index cf9e7cc..0fe9ce1 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -30,6 +30,7 @@ import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultDat import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; +import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import useLockscreenStore from '@/stores/lockscreen/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; @@ -150,7 +151,14 @@ export default function TabLayout() { await securityStore.getState().getRights(); logger.info({ - message: 'Security rights retrieved, connecting SignalR', + message: 'Security rights retrieved, fetching feature flags', + context: { platform: Platform.OS }, + }); + + await featureFlagsStore.getState().fetchFlags(); + + logger.info({ + message: 'Feature flags fetched, connecting SignalR', context: { platform: Platform.OS }, }); @@ -169,18 +177,26 @@ export default function TabLayout() { // Don't fail initialization if SignalR connection fails } - // Connect the realtime chat hub (best-effort; chat may be disabled per department) - try { - await useSignalRStore.getState().connectChatHub(); + // Connect the realtime chat hub only when the Chat.System feature flag is on for + // this department; when it is off every chat surface stays hidden. + if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + try { + await useSignalRStore.getState().connectChatHub(); + logger.info({ + message: 'SignalR chat hub connected successfully', + context: { platform: Platform.OS }, + }); + } catch (error) { + logger.error({ + message: 'Failed to connect SignalR chat hub during initialization', + context: { error, platform: Platform.OS }, + }); + } + } else { logger.info({ - message: 'SignalR chat hub connected successfully', + message: 'Chat disabled by feature flag; skipping chat hub connection', context: { platform: Platform.OS }, }); - } catch (error) { - logger.error({ - message: 'Failed to connect SignalR chat hub during initialization', - context: { error, platform: Platform.OS }, - }); } // Initialize weather alerts diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 0b9cbd0..7344e89 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,4 +1,4 @@ -import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, 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'; @@ -19,6 +19,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); @@ -81,6 +82,7 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); + const isChatEnabled = useIsChatEnabled(); const channels = useChatStore((s) => s.channels); const isLoading = useChatStore((s) => s.isLoadingChannels); const pendingAcks = useChatStore((s) => s.pendingAcks); @@ -89,9 +91,10 @@ export default function ChatScreen() { useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; useChatStore.getState().fetchChannels(); useChatStore.getState().fetchPendingAcks(); - }, []) + }, [isChatEnabled]) ); const grouped = groupChannels(channels); @@ -103,6 +106,11 @@ export default function ChatScreen() { [router] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 05a7c81..1573bb4 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -19,9 +19,11 @@ import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; export default function ChatbotScreen() { const { t } = useTranslation(); + const isChatEnabled = useIsChatEnabled(); const currentUserId = useAuthStore((s) => s.userId); const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); @@ -30,19 +32,21 @@ export default function ChatbotScreen() { useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; const store = useChatStore.getState(); void store.initChatbot(); return () => { useChatStore.getState().setActiveChannel(null); }; - }, []) + }, [isChatEnabled]) ); // Keep the assistant channel active while viewing so incoming messages don't inflate unread. useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId); - }, [chatbotChannelId]) + }, [chatbotChannelId, isChatEnabled]) ); const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); @@ -61,6 +65,11 @@ export default function ChatbotScreen() { [currentUserId] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 3b62f17..f25b523 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -1,5 +1,5 @@ import { Image } from 'expo-image'; -import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { Circle } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,6 +27,7 @@ import { VStack } from '@/components/ui/vstack'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; import { securityStore } from '@/stores/security/store'; import { useToastStore } from '@/stores/toast/store'; @@ -38,6 +39,7 @@ export default function ChannelConversationScreen() { const currentUserId = useAuthStore((s) => s.userId); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; + const isChatEnabled = useIsChatEnabled(); const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); @@ -64,7 +66,7 @@ export default function ChannelConversationScreen() { // Mount: activate channel, join hub, load history and members. useFocusEffect( useCallback(() => { - if (!channelId) return; + if (!channelId || !isChatEnabled) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); void store.joinChannel(channelId); @@ -73,7 +75,7 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId]) + }, [channelId, isChatEnabled]) ); // Fetch presence for the channel members (for the header online dot). @@ -234,6 +236,11 @@ export default function ChannelConversationScreen() { if (channelId) void useChatStore.getState().loadOlderMessages(channelId); }, [channelId]); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); return ( diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx index 69765e8..01074c8 100644 --- a/src/app/chat/thread/[messageId].tsx +++ b/src/app/chat/thread/[messageId].tsx @@ -1,4 +1,4 @@ -import { Stack, useLocalSearchParams } from 'expo-router'; +import { type Href, Redirect, Stack, useLocalSearchParams } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; @@ -16,6 +16,7 @@ import { logger } from '@/lib/logging'; import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; export default function ThreadScreen() { const { t } = useTranslation(); @@ -23,6 +24,7 @@ export default function ThreadScreen() { const messageId = Array.isArray(params.messageId) ? params.messageId[0] : params.messageId; const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; + const isChatEnabled = useIsChatEnabled(); const currentUserId = useAuthStore((s) => s.userId); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); @@ -30,11 +32,11 @@ export default function ThreadScreen() { const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]); useEffect(() => { - if (!messageId) return; + if (!messageId || !isChatEnabled) return; getThread(messageId, undefined, 50) .then((response) => setFetchedReplies(response.Data ?? [])) .catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } })); - }, [messageId]); + }, [messageId, isChatEnabled]); // Merge fetched replies with any realtime/optimistic replies already in the channel cache. const replies = useMemo(() => { @@ -96,6 +98,11 @@ export default function ThreadScreen() { [currentUserId, channelId] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/components/sidebar/side-menu.tsx b/src/components/sidebar/side-menu.tsx index b9f038d..54eb04b 100644 --- a/src/components/sidebar/side-menu.tsx +++ b/src/components/sidebar/side-menu.tsx @@ -23,6 +23,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; + interface SideMenuProps { onNavigate?: () => void; colorScheme?: 'light' | 'dark'; @@ -93,7 +95,9 @@ function SideMenu({ onNavigate, colorScheme: propColorScheme }: SideMenuProps): const router = useRouter(); const { t } = useTranslation(); const [expandedItems, setExpandedItems] = useState>(new Set()); - const menuItems = getMenuItems(t); + const isChatEnabled = useIsChatEnabled(); + // Chat and the assistant are gated by the Chat.System feature flag. + const menuItems = getMenuItems(t).filter((item) => (item.id === 'chat' || item.id === 'assistant' ? isChatEnabled : true)); // Use prop if provided, otherwise default to light on web const isDark = propColorScheme === 'dark'; diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts new file mode 100644 index 0000000..9e137a7 --- /dev/null +++ b/src/stores/feature-flags/store.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags'; +import { logger } from '@/lib/logging'; +import { zustandStorage } from '@/lib/storage'; + +// Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. +export const FeatureFlagKeys = { + ChatSystem: 'Chat.System', +} as const; + +export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys]; + +interface FeatureFlagEntry { + enabled: boolean; + value?: string | null; +} + +export interface FeatureFlagsState { + flags: Record; + isLoaded: boolean; + error: string | null; + fetchFlags: () => Promise; + isEnabled: (key: string, defaultValue?: boolean) => boolean; +} + +export const featureFlagsStore = create()( + persist( + (set, get) => ({ + flags: {}, + isLoaded: false, + error: null, + fetchFlags: async () => { + try { + const response = await getAllFeatureFlags(); + const flags: Record = {}; + for (const flag of response?.Data ?? []) { + if (flag?.Key) { + flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null }; + } + } + set({ flags, isLoaded: true, error: null }); + } catch (error) { + // Keep any persisted flags on failure so gating stays stable while offline. + logger.error({ + message: 'Failed to fetch feature flags', + context: { error }, + }); + set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags' }); + } + }, + isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue, + }), + { + name: 'feature-flags-storage', + storage: createJSONStorage(() => zustandStorage), + } + ) +); + +// Reactive hook; components re-render when the flag changes. Unknown flags default to disabled +// so gated features stay hidden until the server confirms them. +export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue); + +export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); From d52002fd48c211762e3032f8df42932c3907250e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 08:46:00 -0700 Subject: [PATCH 3/6] RG-T117 PR#123 fixes --- src/app/(app)/chat.tsx | 19 +- src/app/(app)/chatbot.tsx | 21 +- src/app/chat/[channelId].tsx | 23 +- src/app/chat/thread/[messageId].tsx | 20 +- .../feature-flags/__tests__/store.test.ts | 225 ++++++++++++++++++ src/stores/feature-flags/store.ts | 63 ++++- src/stores/signalr/signalr-store.ts | 9 + 7 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 src/stores/feature-flags/__tests__/store.test.ts diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 7344e89..b5e9e84 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -15,11 +15,12 @@ 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 { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); @@ -82,7 +83,8 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channels = useChatStore((s) => s.channels); const isLoading = useChatStore((s) => s.isLoadingChannels); const pendingAcks = useChatStore((s) => s.pendingAcks); @@ -106,8 +108,19 @@ export default function ChatScreen() { [router] ); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 1573bb4..49fbf9b 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -14,16 +14,18 @@ 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 { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; export default function ChatbotScreen() { const { t } = useTranslation(); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const currentUserId = useAuthStore((s) => s.userId); const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); @@ -65,8 +67,19 @@ export default function ChatbotScreen() { [currentUserId] ); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + + // Chat.System feature flag off: the assistant rides on the chat system, hide it too. + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index f25b523..9376780 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -27,7 +27,7 @@ import { VStack } from '@/components/ui/vstack'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; import { securityStore } from '@/stores/security/store'; import { useToastStore } from '@/stores/toast/store'; @@ -39,7 +39,8 @@ export default function ChannelConversationScreen() { const currentUserId = useAuthStore((s) => s.userId); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); @@ -236,12 +237,22 @@ export default function ChannelConversationScreen() { if (channelId) void useChatStore.getState().loadOlderMessages(channelId); }, [channelId]); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { - return ; + const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); } - const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + // Chat.System feature flag off: block deep links (push notifications, stale routes). + if (chatStatus === 'disabled') { + return ; + } return ( diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx index 01074c8..6a0a0c9 100644 --- a/src/app/chat/thread/[messageId].tsx +++ b/src/app/chat/thread/[messageId].tsx @@ -10,13 +10,14 @@ 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 { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { logger } from '@/lib/logging'; import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; export default function ThreadScreen() { const { t } = useTranslation(); @@ -24,7 +25,8 @@ export default function ThreadScreen() { const messageId = Array.isArray(params.messageId) ? params.messageId[0] : params.messageId; const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const currentUserId = useAuthStore((s) => s.userId); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); @@ -98,8 +100,18 @@ export default function ThreadScreen() { [currentUserId, channelId] ); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); + } + + // Chat.System feature flag off: block deep links into threads. + if (chatStatus === 'disabled') { return ; } diff --git a/src/stores/feature-flags/__tests__/store.test.ts b/src/stores/feature-flags/__tests__/store.test.ts new file mode 100644 index 0000000..c673876 --- /dev/null +++ b/src/stores/feature-flags/__tests__/store.test.ts @@ -0,0 +1,225 @@ +import { renderHook } from '@testing-library/react-native'; + +import { FeatureFlagKeys, featureFlagsStore, useChatSystemStatus } from '../store'; + +// Mock the API +jest.mock('@/api/feature-flags/feature-flags', () => ({ + getAllFeatureFlags: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock the storage +jest.mock('@/lib/storage', () => ({ + zustandStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock identity sources +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + getState: jest.fn(), + }, +})); + +jest.mock('../../security/store', () => ({ + securityStore: { + getState: jest.fn(), + }, +})); + +const { getAllFeatureFlags } = require('@/api/feature-flags/feature-flags'); +const useAuthStore = require('../../auth/store').default; +const { securityStore } = require('../../security/store'); + +const setIdentity = (userId: string | null, departmentId: string | null) => { + useAuthStore.getState.mockReturnValue({ userId }); + securityStore.getState.mockReturnValue({ + rights: departmentId ? { DepartmentId: departmentId } : null, + }); +}; + +describe('Feature Flags Store', () => { + beforeEach(() => { + jest.clearAllMocks(); + featureFlagsStore.setState({ + flags: {}, + isLoaded: false, + error: null, + identityKey: null, + }); + setIdentity('user-1', 'dept-1'); + }); + + describe('fetchFlags', () => { + it('should store flags and stamp the current identity on success', async () => { + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: true, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]).toEqual({ enabled: true, value: null }); + expect(state.isLoaded).toBe(true); + expect(state.error).toBeNull(); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep persisted flags on failure for the same identity', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + expect(state.error).toBe('network down'); + }); + + it('should clear flags from a different department before fetching so a failed fetch cannot reuse them', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-old', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags).toEqual({}); + // Fail-closed: the failed fetch still resolves the flags so consumers stop waiting. + expect(state.isLoaded).toBe(true); + expect(state.identityKey).toBeNull(); + }); + + it('should clear flags from a different account before fetching', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + + it('should replace another identity flags with fresh ones on success', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-other', + }); + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: false, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(false); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep flags on failure when department is unknown but the user matches', async () => { + setIdentity('user-1', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should clear flags when department is unknown and the user differs', async () => { + setIdentity('user-2', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + }); + + describe('useChatSystemStatus', () => { + it('should be unknown before the initial fetch resolves', () => { + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('unknown'); + }); + + it('should report enabled and disabled from the flag entry', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + const { result: enabled } = renderHook(() => useChatSystemStatus()); + expect(enabled.current).toBe('enabled'); + + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: false, value: null } }, + }); + const { result: disabled } = renderHook(() => useChatSystemStatus()); + expect(disabled.current).toBe('disabled'); + }); + + it('should resolve disabled when flags loaded without an entry', () => { + featureFlagsStore.setState({ flags: {}, isLoaded: true }); + + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('disabled'); + }); + + it('should resolve disabled (fail-closed) after a failed fetch with no persisted flags', async () => { + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const { result } = renderHook(() => useChatSystemStatus()); + expect(result.current).toBe('disabled'); + }); + }); + + describe('isEnabled', () => { + it('should return the flag state when present and the default when missing', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + + expect(featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)).toBe(true); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag')).toBe(false); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag', true)).toBe(true); + }); + }); +}); diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts index 9e137a7..f93b7a9 100644 --- a/src/stores/feature-flags/store.ts +++ b/src/stores/feature-flags/store.ts @@ -5,6 +5,9 @@ import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags'; import { logger } from '@/lib/logging'; import { zustandStorage } from '@/lib/storage'; +import useAuthStore from '../auth/store'; +import { securityStore } from '../security/store'; + // Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. export const FeatureFlagKeys = { ChatSystem: 'Chat.System', @@ -17,10 +20,38 @@ interface FeatureFlagEntry { value?: string | null; } +// Immutable ids only (user id + department id) so renames/code changes never alias identities. +const getCurrentIdentityKey = (): string | null => { + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (!userId || !departmentId) { + return null; + } + return `${userId}:${departmentId}`; +}; + +// True only when the persisted flags provably belong to a different account/department. +// With no proof (e.g. rights unavailable offline) flags are kept so gating stays stable. +const isPersistedIdentityStale = (persistedKey: string | null): boolean => { + if (!persistedKey) { + return false; + } + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (userId && departmentId) { + return persistedKey !== `${userId}:${departmentId}`; + } + if (userId) { + return !persistedKey.startsWith(`${userId}:`); + } + return false; +}; + export interface FeatureFlagsState { flags: Record; isLoaded: boolean; error: string | null; + identityKey: string | null; fetchFlags: () => Promise; isEnabled: (key: string, defaultValue?: boolean) => boolean; } @@ -31,7 +62,14 @@ export const featureFlagsStore = create()( flags: {}, isLoaded: false, error: null, + identityKey: null, fetchFlags: async () => { + const identityKey = getCurrentIdentityKey(); + if (isPersistedIdentityStale(get().identityKey)) { + // Persisted flags belong to another account/department; drop them before fetching + // so a failed fetch can never gate this identity with the previous one's flags. + set({ flags: {}, isLoaded: false, identityKey: null }); + } try { const response = await getAllFeatureFlags(); const flags: Record = {}; @@ -40,14 +78,17 @@ export const featureFlagsStore = create()( flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null }; } } - set({ flags, isLoaded: true, error: null }); + set({ flags, isLoaded: true, error: null, identityKey }); } catch (error) { - // Keep any persisted flags on failure so gating stays stable while offline. + // Keep persisted flags on failure so gating stays stable while offline; the mismatch + // check above already cleared them if they belonged to a different identity. Marking + // isLoaded resolves flags with no persisted entry fail-closed (disabled) instead of + // leaving consumers waiting on 'unknown' forever. logger.error({ message: 'Failed to fetch feature flags', context: { error }, }); - set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags' }); + set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags', isLoaded: true }); } }, isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue, @@ -64,3 +105,19 @@ export const featureFlagsStore = create()( export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue); export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); + +export type FeatureFlagStatus = 'unknown' | 'enabled' | 'disabled'; + +// Tri-state hook for gating that must not act before flags resolve (e.g. redirecting away +// from a deep link). 'unknown' until flags for this identity are fetched or rehydrated; +// fetch failures resolve fail-closed as 'disabled' for flags with no persisted entry. +export const useFeatureFlagStatus = (key: string): FeatureFlagStatus => + featureFlagsStore((state) => { + const entry = state.flags[key]; + if (entry) { + return entry.enabled ? 'enabled' : 'disabled'; + } + return state.isLoaded ? 'disabled' : 'unknown'; + }); + +export const useChatSystemStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.ChatSystem); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 0cc6e96..a1be3c4 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,7 @@ import { signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; +import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore, useSecurityStore } from '../security/store'; /** Client-event method names raised by the chat SignalR hub. */ @@ -621,6 +622,14 @@ export const useSignalRStore = create((set, get) => ({ }, connectChatHub: async () => { try { + // Guard here so every call path (init, app-resume reconnect) honors the flag. + if (!featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + logger.info({ + message: 'Chat disabled by feature flag; skipping chat hub connection', + }); + return; + } + if (get().isChatHubConnected) { return; } From dd608fcc0e17bba14eb3d62ea488533a7cf80074 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 09:01:45 -0700 Subject: [PATCH 4/6] RG-T117 PR#123 fix --- src/stores/signalr/signalr-store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index a1be3c4..e75530c 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -627,6 +627,8 @@ export const useSignalRStore = create((set, get) => ({ logger.info({ message: 'Chat disabled by feature flag; skipping chat hub connection', }); + // Tear down any existing connection so a runtime flag flip-off disconnects the hub. + await get().disconnectChatHub(); return; } From 2dba29af9f43b20305577d862c0d93dc1116d3a3 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 13:39:20 -0700 Subject: [PATCH 5/6] RG-T117 Chatbot fixes --- src/api/chat/chatbot.ts | 4 +- src/app/(app)/chat.tsx | 7 ++ src/app/(app)/chatbot.tsx | 64 ++++++++++++++++++- src/app/chat/[channelId].tsx | 6 ++ src/app/chat/thread/[messageId].tsx | 2 +- src/components/chat/message-actions-sheet.tsx | 30 +++++---- src/components/chat/message-composer.tsx | 14 ++-- src/models/v4/chat/chatbotModels.ts | 18 ++++-- src/stores/chat/store.ts | 4 +- 9 files changed, 121 insertions(+), 28 deletions(-) diff --git a/src/api/chat/chatbot.ts b/src/api/chat/chatbot.ts index 383b9f5..8989d97 100644 --- a/src/api/chat/chatbot.ts +++ b/src/api/chat/chatbot.ts @@ -7,7 +7,7 @@ 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; + return response.data?.Data ?? null; }; /** @@ -19,7 +19,7 @@ export const sendChatbotMessage = async (text: string, clientMessageId: string) Text: text, ClientMessageId: clientMessageId, }); - return response.data; + return response.data?.Data ?? null; }; /** Resets the chatbot conversational session (message history is retained). */ diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index b5e9e84..6b6baf0 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -103,6 +103,13 @@ export default function ChatScreen() { const openChannel = useCallback( (channelId: string) => { + // The assistant conversation always opens in its dedicated restricted screen + // (text only, no reactions/threads/deletes) instead of the generic conversation. + const channel = useChatStore.getState().channels.find((c) => c.ChatChannelId === channelId); + if (channel?.ChannelType === ChatChannelType.Chatbot) { + router.push('/chatbot' as Href); + return; + } router.push(`/chat/${channelId}` as Href); }, [router] diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 49fbf9b..6abe41f 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -4,9 +4,13 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; +import { copyToClipboard } from '@/components/chat/chat-utils'; +import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; import { TypingDots } from '@/components/chat/typing-indicator'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; +import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; @@ -16,11 +20,14 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; +import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; import { useChatSystemStatus } from '@/stores/feature-flags/store'; +import { securityStore } from '@/stores/security/store'; +import { useToastStore } from '@/stores/toast/store'; export default function ChatbotScreen() { const { t } = useTranslation(); @@ -30,7 +37,11 @@ export default function ChatbotScreen() { const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); + const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; const [text, setText] = useState(''); + const [actionsMessage, setActionsMessage] = useState(null); + const [editMessage, setEditMessage] = useState(null); + const [editText, setEditText] = useState(''); useFocusEffect( useCallback(() => { @@ -62,7 +73,7 @@ export default function ChatbotScreen() { const renderItem = useCallback( ({ item }: { item: ChatMessageResultData }) => ( - undefined} onToggleReaction={() => undefined} /> + undefined} /> ), [currentUserId] ); @@ -135,6 +146,57 @@ export default function ChatbotScreen() { + + {/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */} + setActionsMessage(null)} + isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId} + isModerator={isModerator} + assistant + onReact={() => undefined} + onReply={() => undefined} + 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={() => undefined} + onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)} + onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)} + onModeratorDelete={() => undefined} + /> + + {/* Edit own message */} + setEditMessage(null)}> + + + + + + + {t('chat.edit_message')} + + + + + ); } diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 9376780..944bc54 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -254,6 +254,12 @@ export default function ChannelConversationScreen() { return ; } + // Assistant conversations always use the dedicated restricted screen (text only, + // no reactions/threads/deletes) — catch deep links and stale routes here. + if (channel?.ChannelType === ChatChannelType.Chatbot) { + return ; + } + return ( item.ChatMessageId} renderItem={renderItem} contentContainerStyle={{ paddingVertical: 8 }} /> - undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} /> + undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} /> ); diff --git a/src/components/chat/message-actions-sheet.tsx b/src/components/chat/message-actions-sheet.tsx index 97fd764..1bc66c2 100644 --- a/src/components/chat/message-actions-sheet.tsx +++ b/src/components/chat/message-actions-sheet.tsx @@ -16,6 +16,8 @@ interface MessageActionsSheetProps { onClose: () => void; isOwn: boolean; isModerator: boolean; + /** Assistant conversations: no reactions, threads or deletes — copy, edit own, pin and flag stay. */ + assistant?: boolean; onReact: (message: ChatMessageResultData, emoji: string) => void; onReply: (message: ChatMessageResultData) => void; onCopy: (message: ChatMessageResultData) => void; @@ -26,7 +28,7 @@ interface MessageActionsSheetProps { onModeratorDelete: (message: ChatMessageResultData) => void; } -export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { +export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, assistant = false, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { const { t } = useTranslation(); const [mode, setMode] = useState<'actions' | 'flag'>('actions'); @@ -75,7 +77,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : ( <> - {!isDeleted ? ( + {!isDeleted && !assistant ? ( {QUICK_REACTIONS.map((emoji) => ( ) : null} - { - onReply(message); - close(); - }} - > - - {t('chat.reply_in_thread')} - + {!assistant ? ( + { + onReply(message); + close(); + }} + > + + {t('chat.reply_in_thread')} + + ) : null} {isText && !isDeleted ? ( ) : null} - {isOwn && !isDeleted ? ( + {isOwn && !isDeleted && !assistant ? ( { onDelete(message); @@ -157,7 +161,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : null} - {isModerator && !isDeleted ? ( + {isModerator && !isDeleted && !assistant ? ( { onModeratorDelete(message); diff --git a/src/components/chat/message-composer.tsx b/src/components/chat/message-composer.tsx index d7fb68b..45a4f14 100644 --- a/src/components/chat/message-composer.tsx +++ b/src/components/chat/message-composer.tsx @@ -25,9 +25,11 @@ interface MessageComposerProps { onTyping: (isTyping: boolean) => void; disabled?: boolean; placeholder?: string; + /** Urgent priority is channel-level only; thread replies pass false to hide the toggle. */ + allowUrgent?: boolean; } -export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpenGif, onTyping, disabled, placeholder }: MessageComposerProps) { +export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpenGif, onTyping, disabled, placeholder, allowUrgent = true }: MessageComposerProps) { const { t } = useTranslation(); const [text, setText] = useState(''); const [urgent, setUrgent] = useState(false); @@ -132,16 +134,18 @@ export function MessageComposer({ onSendText, onSendImage, onSendLocation, onOpe - setUrgent((prev) => !prev)} disabled={disabled} accessibilityLabel={t('chat.urgent')}> - - + {allowUrgent ? ( + setUrgent((prev) => !prev)} disabled={disabled} accessibilityLabel={t('chat.urgent')}> + + + ) : null} - {urgent ? ( + {allowUrgent && urgent ? ( {t('chat.urgent_will_send')} diff --git a/src/models/v4/chat/chatbotModels.ts b/src/models/v4/chat/chatbotModels.ts index 4f664c2..61daa56 100644 --- a/src/models/v4/chat/chatbotModels.ts +++ b/src/models/v4/chat/chatbotModels.ts @@ -1,21 +1,29 @@ /** - * Chatbot (assistant) API response shapes. Unlike the Chat controller, the - * Chatbot web-chat endpoints return plain objects (not the { Data } envelope). + * Chatbot (assistant) API response shapes. Like the Chat controller, the chatbot + * web-chat endpoints wrap their payload in the standard v4 { Data } envelope. */ -export interface ChatbotChannelResponse { +export interface ChatbotChannelData { ChatChannelId: string; Name?: string | null; LastMessageSeq: number; LastMessageOn?: string | null; } -export interface ChatbotSendResponse { +export interface ChatbotChannelResponse { + Data?: ChatbotChannelData | null; +} + +export interface ChatbotSendData { ChatMessageId: string; MessageSeq: number; SentOn: string; } +export interface ChatbotSendResponse { + Data?: ChatbotSendData | null; +} + export interface ChatbotSessionResponse { - success: boolean; + Success: boolean; } diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 15b9c50..0631815 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -731,8 +731,10 @@ export const useChatStore = create()( }, handleAckRequired: (raw: unknown) => { - const ack = parseEventData(raw); + const ack = parseEventData(raw); if (!ack || !ack.ChatMessageId) return; + // The sender never has to acknowledge their own urgent message. + if (ack.SenderUserId && ack.SenderUserId === currentUserId()) return; set((s) => (s.pendingAcks.some((a) => a.ChatMessageId === ack.ChatMessageId) ? {} : { pendingAcks: [...s.pendingAcks, ack] })); }, From 6fd09bf05b8761db1953d49bf5f46ce675267a9a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 14:20:25 -0700 Subject: [PATCH 6/6] RG-T117 PR#123 fixes --- package.json | 1 + src/app/chat/[channelId].tsx | 40 ++++++++++++-- .../chat/__tests__/chat-utils.test.ts | 55 ++++++++++++++++++- src/components/chat/chat-utils.ts | 15 +++-- yarn.lock | 5 ++ 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 2574879..ba1465e 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "expo-auth-session": "~7.0.11", "expo-av": "~16.0.8", "expo-build-properties": "~1.0.10", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.9", "expo-dev-client": "~6.0.21", diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 944bc54..3618204 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -56,18 +56,35 @@ export default function ChannelConversationScreen() { const [editText, setEditText] = useState(''); const [imageUri, setImageUri] = useState(null); const [presenceIds, setPresenceIds] = useState>(new Set()); + const [resolveAttempted, setResolveAttempted] = useState(false); const unsubscribeRef = useRef<(() => void) | null>(null); const isDm = channel?.ChannelType === ChatChannelType.DirectMessage; const showSender = !isDm; + const isChatbot = channel?.ChannelType === ChatChannelType.Chatbot; + // Deep links (push notifications, cold starts) can arrive before the channel + // list loads; the channel type is unknown until then. Treat a completed fetch + // with no match as resolved so unknown channels keep the generic screen. + const isResolved = !!channel || resolveAttempted; // Newest-first for the inverted list. const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); - // Mount: activate channel, join hub, load history and members. + // Resolve the channel identity for deep links before mounting the generic view. + useEffect(() => { + if (channel || resolveAttempted || !isChatEnabled) return; + void useChatStore + .getState() + .fetchChannels() + .finally(() => setResolveAttempted(true)); + }, [channel, resolveAttempted, isChatEnabled]); + + // Mount: activate channel, join hub, load history and members. Assistant + // conversations are handled by the dedicated chatbot screen — never join or + // load them here, and wait for unresolved deep links to identify first. useFocusEffect( useCallback(() => { - if (!channelId || !isChatEnabled) return; + if (!channelId || !isChatEnabled || !isResolved || isChatbot) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); void store.joinChannel(channelId); @@ -76,7 +93,7 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId, isChatEnabled]) + }, [channelId, isChatEnabled, isResolved, isChatbot]) ); // Fetch presence for the channel members (for the header online dot). @@ -96,10 +113,10 @@ export default function ChannelConversationScreen() { // Mark read whenever the newest message changes while viewing. useEffect(() => { - if (channelId && inverted.length > 0) { + if (channelId && isResolved && !isChatbot && inverted.length > 0) { void useChatStore.getState().markChannelRead(channelId); } - }, [channelId, inverted.length]); + }, [channelId, inverted.length, isResolved, isChatbot]); const otherOnline = useMemo(() => { if (!isDm) return false; @@ -254,9 +271,20 @@ export default function ChannelConversationScreen() { return ; } + // Deep link to a channel that isn't loaded yet: wait for the channel list so + // assistant conversations never mount the full-featured view. + if (!isResolved) { + return ( + + + + + ); + } + // Assistant conversations always use the dedicated restricted screen (text only, // no reactions/threads/deletes) — catch deep links and stale routes here. - if (channel?.ChannelType === ChatChannelType.Chatbot) { + if (isChatbot) { return ; } diff --git a/src/components/chat/__tests__/chat-utils.test.ts b/src/components/chat/__tests__/chat-utils.test.ts index 9fe5ba3..248d1ce 100644 --- a/src/components/chat/__tests__/chat-utils.test.ts +++ b/src/components/chat/__tests__/chat-utils.test.ts @@ -1,8 +1,11 @@ +import * as Clipboard from 'expo-clipboard'; import { type TFunction } from 'i18next'; import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat'; -import { getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils'; +import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils'; + +jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() })); const mockT = ((key: string) => key) as TFunction; @@ -74,6 +77,56 @@ describe('chat-utils', () => { }); }); + describe('copyToClipboard', () => { + const globalWithNavigator = globalThis as unknown as { navigator?: { clipboard?: { writeText?: (value: string) => Promise } } }; + let originalNavigator: unknown; + + beforeEach(() => { + originalNavigator = globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockReset(); + }); + + afterEach(() => { + if (originalNavigator === undefined) { + delete globalWithNavigator.navigator; + } else { + globalWithNavigator.navigator = originalNavigator as typeof globalWithNavigator.navigator; + } + }); + + it('uses the web clipboard API when available', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + globalWithNavigator.navigator = { clipboard: { writeText } }; + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('hello'); + expect(Clipboard.setStringAsync).not.toHaveBeenCalled(); + }); + + it('falls back to the native module when the web API is unavailable', async () => { + delete globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true); + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello'); + }); + + it('falls back to the native module when the web API write fails', async () => { + globalWithNavigator.navigator = { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } }; + jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true); + + await expect(copyToClipboard('hello')).resolves.toBe(true); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello'); + }); + + it('returns false when the native write fails', async () => { + delete globalWithNavigator.navigator; + jest.mocked(Clipboard.setStringAsync).mockRejectedValue(new Error('unavailable')); + + await expect(copyToClipboard('hello')).resolves.toBe(false); + }); + }); + describe('linkifySegments', () => { it('splits multiple links and surrounding text', () => { expect(linkifySegments('go to https://a.com or http://b.com now')).toEqual([ diff --git a/src/components/chat/chat-utils.ts b/src/components/chat/chat-utils.ts index e6b2aca..ac54751 100644 --- a/src/components/chat/chat-utils.ts +++ b/src/components/chat/chat-utils.ts @@ -1,3 +1,4 @@ +import * as Clipboard from 'expo-clipboard'; import { type TFunction } from 'i18next'; import { getAvatarUrl } from '@/lib/utils'; @@ -105,9 +106,9 @@ export function hasLink(body?: string | null): boolean { } /** - * 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. + * Copies text to the clipboard. Uses the async Clipboard API on web/Electron + * and expo-clipboard on native; returns false only when both are unavailable + * or the write fails, so callers can surface an appropriate message. */ export async function copyToClipboard(text: string): Promise { try { @@ -117,9 +118,13 @@ export async function copyToClipboard(text: string): Promise { return true; } } catch { - // ignore and fall through + // ignore and fall through to the native module + } + try { + return await Clipboard.setStringAsync(text); + } catch { + return false; } - return false; } const IMAGE_MIME_BY_EXTENSION: Record = { diff --git a/yarn.lock b/yarn.lock index d3c69af..cf802e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8047,6 +8047,11 @@ expo-build-properties@~1.0.10: ajv "^8.11.0" semver "^7.6.0" +expo-clipboard@~8.0.8: + version "8.0.8" + resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" + integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== + expo-constants@~18.0.13: version "18.0.13" resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"