Skip to content

Commit 945dc9b

Browse files
authored
Merge pull request #123 from Resgrid/develop
Develop
2 parents f66174d + 6fd09bf commit 945dc9b

20 files changed

Lines changed: 754 additions & 59 deletions

File tree

docs/audio-stream-refactoring.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Audio Stream Store Refactoring
22

3+
## Expo SDK 56 migration requirement
4+
5+
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.
6+
7+
Migration checklist:
8+
9+
- 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`.
10+
- Remove `expo-av` from `package.json`, the lockfile, tests/mocks, and the Expo Doctor exclusion after no imports remain.
11+
- 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.
12+
- 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.
13+
- Also test PTT, call audio, background playback, interruptions, and Bluetooth/headset routing before release.
14+
15+
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`.
16+
317
## Overview
418

519
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.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"expo-auth-session": "~7.0.11",
128128
"expo-av": "~16.0.8",
129129
"expo-build-properties": "~1.0.10",
130+
"expo-clipboard": "~8.0.8",
130131
"expo-constants": "~18.0.13",
131132
"expo-crypto": "~15.0.9",
132133
"expo-dev-client": "~6.0.21",

src/api/chat/chatbot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const CHATBOT = '/Chatbot';
77
/** Gets (creating if needed) the caller's chatbot conversation channel. */
88
export const getChatbotChannel = async (signal?: AbortSignal) => {
99
const response = await api.get<ChatbotChannelResponse>(`${CHATBOT}/GetChatChannel`, { signal });
10-
return response.data;
10+
return response.data?.Data ?? null;
1111
};
1212

1313
/**
@@ -19,7 +19,7 @@ export const sendChatbotMessage = async (text: string, clientMessageId: string)
1919
Text: text,
2020
ClientMessageId: clientMessageId,
2121
});
22-
return response.data;
22+
return response.data?.Data ?? null;
2323
};
2424

2525
/** Resets the chatbot conversational session (message history is retained). */
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { api } from '../common/client';
2+
3+
const FEATURE_TOGGLES = '/FeatureToggles';
4+
5+
// ---------------------------------------------------------------------------
6+
// Feature toggle evaluation (department-scoped, any authenticated user).
7+
// Backed by the v4 FeatureToggles API; keys live in Resgrid.Model.FeatureFlagKeys.
8+
// ---------------------------------------------------------------------------
9+
10+
export interface FeatureToggleData {
11+
Key: string;
12+
Enabled: boolean;
13+
Value?: string | null;
14+
ValueType?: string | null;
15+
Source?: string | null;
16+
}
17+
18+
export interface FeatureTogglesResult {
19+
Data?: FeatureToggleData[];
20+
StateHash?: string;
21+
}
22+
23+
export interface FeatureToggleResult {
24+
Data?: FeatureToggleData;
25+
}
26+
27+
/** Evaluates every active flag for the caller's department. */
28+
export const getAllFeatureFlags = async (signal?: AbortSignal) => {
29+
const response = await api.get<FeatureTogglesResult>(`${FEATURE_TOGGLES}/GetAll`, { signal });
30+
return response.data;
31+
};
32+
33+
/** Lightweight enabled-only check for a single flag. */
34+
export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => {
35+
const response = await api.get<FeatureToggleResult>(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal });
36+
return response.data;
37+
};

src/app/(app)/_layout.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultDat
3030
import { usePushNotifications } from '@/services/push-notification';
3131
import { useCoreStore } from '@/stores/app/core-store';
3232
import { useCallsStore } from '@/stores/calls/store';
33+
import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store';
3334
import useLockscreenStore from '@/stores/lockscreen/store';
3435
import { useRolesStore } from '@/stores/roles/store';
3536
import { securityStore } from '@/stores/security/store';
@@ -150,7 +151,14 @@ export default function TabLayout() {
150151
await securityStore.getState().getRights();
151152

152153
logger.info({
153-
message: 'Security rights retrieved, connecting SignalR',
154+
message: 'Security rights retrieved, fetching feature flags',
155+
context: { platform: Platform.OS },
156+
});
157+
158+
await featureFlagsStore.getState().fetchFlags();
159+
160+
logger.info({
161+
message: 'Feature flags fetched, connecting SignalR',
154162
context: { platform: Platform.OS },
155163
});
156164

@@ -169,18 +177,26 @@ export default function TabLayout() {
169177
// Don't fail initialization if SignalR connection fails
170178
}
171179

172-
// Connect the realtime chat hub (best-effort; chat may be disabled per department)
173-
try {
174-
await useSignalRStore.getState().connectChatHub();
180+
// Connect the realtime chat hub only when the Chat.System feature flag is on for
181+
// this department; when it is off every chat surface stays hidden.
182+
if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
183+
try {
184+
await useSignalRStore.getState().connectChatHub();
185+
logger.info({
186+
message: 'SignalR chat hub connected successfully',
187+
context: { platform: Platform.OS },
188+
});
189+
} catch (error) {
190+
logger.error({
191+
message: 'Failed to connect SignalR chat hub during initialization',
192+
context: { error, platform: Platform.OS },
193+
});
194+
}
195+
} else {
175196
logger.info({
176-
message: 'SignalR chat hub connected successfully',
197+
message: 'Chat disabled by feature flag; skipping chat hub connection',
177198
context: { platform: Platform.OS },
178199
});
179-
} catch (error) {
180-
logger.error({
181-
message: 'Failed to connect SignalR chat hub during initialization',
182-
context: { error, platform: Platform.OS },
183-
});
184200
}
185201

186202
// Initialize weather alerts

src/app/(app)/chat.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router';
1+
import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router';
22
import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native';
33
import React, { useCallback, useState } from 'react';
44
import { useTranslation } from 'react-i18next';
@@ -15,10 +15,12 @@ import { Fab, FabIcon } from '@/components/ui/fab';
1515
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
1616
import { HStack } from '@/components/ui/hstack';
1717
import { Pressable } from '@/components/ui/pressable';
18+
import { Spinner } from '@/components/ui/spinner';
1819
import { Text } from '@/components/ui/text';
1920
import { VStack } from '@/components/ui/vstack';
2021
import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat';
2122
import { useChatStore } from '@/stores/chat/store';
23+
import { useChatSystemStatus } from '@/stores/feature-flags/store';
2224

2325
function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) {
2426
const { t } = useTranslation();
@@ -81,6 +83,8 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha
8183
export default function ChatScreen() {
8284
const { t } = useTranslation();
8385
const router = useRouter();
86+
const chatStatus = useChatSystemStatus();
87+
const isChatEnabled = chatStatus === 'enabled';
8488
const channels = useChatStore((s) => s.channels);
8589
const isLoading = useChatStore((s) => s.isLoadingChannels);
8690
const pendingAcks = useChatStore((s) => s.pendingAcks);
@@ -89,20 +93,44 @@ export default function ChatScreen() {
8993

9094
useFocusEffect(
9195
useCallback(() => {
96+
if (!isChatEnabled) return;
9297
useChatStore.getState().fetchChannels();
9398
useChatStore.getState().fetchPendingAcks();
94-
}, [])
99+
}, [isChatEnabled])
95100
);
96101

97102
const grouped = groupChannels(channels);
98103

99104
const openChannel = useCallback(
100105
(channelId: string) => {
106+
// The assistant conversation always opens in its dedicated restricted screen
107+
// (text only, no reactions/threads/deletes) instead of the generic conversation.
108+
const channel = useChatStore.getState().channels.find((c) => c.ChatChannelId === channelId);
109+
if (channel?.ChannelType === ChatChannelType.Chatbot) {
110+
router.push('/chatbot' as Href);
111+
return;
112+
}
101113
router.push(`/chat/${channelId}` as Href);
102114
},
103115
[router]
104116
);
105117

118+
// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
119+
if (chatStatus === 'unknown') {
120+
return (
121+
<Box className="size-full flex-1 items-center justify-center bg-background-0">
122+
<Stack.Screen options={{ headerShown: false }} />
123+
<FocusAwareStatusBar />
124+
<Spinner />
125+
</Box>
126+
);
127+
}
128+
129+
// Chat.System feature flag off: no chat for this department.
130+
if (chatStatus === 'disabled') {
131+
return <Redirect href={'/home' as Href} />;
132+
}
133+
106134
return (
107135
<Box className="size-full flex-1 bg-background-0">
108136
<Stack.Screen options={{ headerShown: false }} />

src/app/(app)/chatbot.tsx

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,65 @@
1-
import { Stack, useFocusEffect } from 'expo-router';
1+
import { type Href, Redirect, Stack, useFocusEffect } from 'expo-router';
22
import { RefreshCw, Send, Sparkles } from 'lucide-react-native';
33
import React, { useCallback, useMemo, useState } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import { Platform } from 'react-native';
66

7+
import { copyToClipboard } from '@/components/chat/chat-utils';
8+
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
79
import { MessageBubble } from '@/components/chat/message-bubble';
810
import { TypingDots } from '@/components/chat/typing-indicator';
11+
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
912
import { Box } from '@/components/ui/box';
13+
import { Button, ButtonText } from '@/components/ui/button';
1014
import { Center } from '@/components/ui/center';
1115
import { FlatList } from '@/components/ui/flat-list';
1216
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
1317
import { HStack } from '@/components/ui/hstack';
1418
import { Input, InputField } from '@/components/ui/input';
1519
import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
1620
import { Pressable } from '@/components/ui/pressable';
21+
import { Spinner } from '@/components/ui/spinner';
1722
import { Text } from '@/components/ui/text';
23+
import { Textarea, TextareaInput } from '@/components/ui/textarea';
1824
import { VStack } from '@/components/ui/vstack';
1925
import { type ChatMessageResultData } from '@/models/v4/chat';
2026
import useAuthStore from '@/stores/auth/store';
2127
import { useChatStore } from '@/stores/chat/store';
28+
import { useChatSystemStatus } from '@/stores/feature-flags/store';
29+
import { securityStore } from '@/stores/security/store';
30+
import { useToastStore } from '@/stores/toast/store';
2231

2332
export default function ChatbotScreen() {
2433
const { t } = useTranslation();
34+
const chatStatus = useChatSystemStatus();
35+
const isChatEnabled = chatStatus === 'enabled';
2536
const currentUserId = useAuthStore((s) => s.userId);
2637
const chatbotChannelId = useChatStore((s) => s.chatbotChannelId);
2738
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
2839
const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined));
40+
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
2941
const [text, setText] = useState('');
42+
const [actionsMessage, setActionsMessage] = useState<ChatMessageResultData | null>(null);
43+
const [editMessage, setEditMessage] = useState<ChatMessageResultData | null>(null);
44+
const [editText, setEditText] = useState('');
3045

3146
useFocusEffect(
3247
useCallback(() => {
48+
if (!isChatEnabled) return;
3349
const store = useChatStore.getState();
3450
void store.initChatbot();
3551
return () => {
3652
useChatStore.getState().setActiveChannel(null);
3753
};
38-
}, [])
54+
}, [isChatEnabled])
3955
);
4056

4157
// Keep the assistant channel active while viewing so incoming messages don't inflate unread.
4258
useFocusEffect(
4359
useCallback(() => {
60+
if (!isChatEnabled) return;
4461
if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId);
45-
}, [chatbotChannelId])
62+
}, [chatbotChannelId, isChatEnabled])
4663
);
4764

4865
const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]);
@@ -56,11 +73,27 @@ export default function ChatbotScreen() {
5673

5774
const renderItem = useCallback(
5875
({ item }: { item: ChatMessageResultData }) => (
59-
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={() => undefined} onToggleReaction={() => undefined} />
76+
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={setActionsMessage} onToggleReaction={() => undefined} />
6077
),
6178
[currentUserId]
6279
);
6380

81+
// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
82+
if (chatStatus === 'unknown') {
83+
return (
84+
<Box className="size-full flex-1 items-center justify-center bg-background-0">
85+
<Stack.Screen options={{ headerShown: false }} />
86+
<FocusAwareStatusBar />
87+
<Spinner />
88+
</Box>
89+
);
90+
}
91+
92+
// Chat.System feature flag off: the assistant rides on the chat system, hide it too.
93+
if (chatStatus === 'disabled') {
94+
return <Redirect href={'/home' as Href} />;
95+
}
96+
6497
return (
6598
<Box className="size-full flex-1 bg-background-0">
6699
<Stack.Screen options={{ headerShown: false }} />
@@ -113,6 +146,57 @@ export default function ChatbotScreen() {
113146
</Pressable>
114147
</HStack>
115148
</KeyboardAvoidingView>
149+
150+
{/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */}
151+
<MessageActionsSheet
152+
message={actionsMessage}
153+
isOpen={actionsMessage !== null}
154+
onClose={() => setActionsMessage(null)}
155+
isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId}
156+
isModerator={isModerator}
157+
assistant
158+
onReact={() => undefined}
159+
onReply={() => undefined}
160+
onCopy={async (m) => {
161+
const ok = await copyToClipboard(m.Body ?? '');
162+
useToastStore.getState().showToast(ok ? 'success' : 'info', ok ? t('chat.copied') : t('chat.copy_unavailable'));
163+
}}
164+
onEdit={(m) => {
165+
setEditMessage(m);
166+
setEditText(m.Body ?? '');
167+
}}
168+
onDelete={() => undefined}
169+
onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)}
170+
onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)}
171+
onModeratorDelete={() => undefined}
172+
/>
173+
174+
{/* Edit own message */}
175+
<Actionsheet isOpen={editMessage !== null} onClose={() => setEditMessage(null)}>
176+
<ActionsheetBackdrop />
177+
<ActionsheetContent>
178+
<ActionsheetDragIndicatorWrapper>
179+
<ActionsheetDragIndicator />
180+
</ActionsheetDragIndicatorWrapper>
181+
<VStack className="w-full p-2" space="md">
182+
<Text className="text-base font-semibold text-typography-900">{t('chat.edit_message')}</Text>
183+
<Textarea>
184+
<TextareaInput value={editText} onChangeText={setEditText} multiline />
185+
</Textarea>
186+
<Button
187+
className="bg-primary-600"
188+
onPress={() => {
189+
if (editMessage && chatbotChannelId && editText.trim()) {
190+
void useChatStore.getState().editMessage(editMessage.ChatMessageId, chatbotChannelId, editText.trim());
191+
}
192+
setEditMessage(null);
193+
}}
194+
>
195+
<ButtonText>{t('chat.save')}</ButtonText>
196+
</Button>
197+
</VStack>
198+
</ActionsheetContent>
199+
</Actionsheet>
116200
</Box>
117201
);
118202
}

0 commit comments

Comments
 (0)