Skip to content

Commit bb6b160

Browse files
committed
RG-T117 chat feature flag
1 parent fa1677d commit bb6b160

8 files changed

Lines changed: 176 additions & 22 deletions

File tree

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: 10 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';
@@ -19,6 +19,7 @@ import { Text } from '@/components/ui/text';
1919
import { VStack } from '@/components/ui/vstack';
2020
import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat';
2121
import { useChatStore } from '@/stores/chat/store';
22+
import { useIsChatEnabled } from '@/stores/feature-flags/store';
2223

2324
function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) {
2425
const { t } = useTranslation();
@@ -81,6 +82,7 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha
8182
export default function ChatScreen() {
8283
const { t } = useTranslation();
8384
const router = useRouter();
85+
const isChatEnabled = useIsChatEnabled();
8486
const channels = useChatStore((s) => s.channels);
8587
const isLoading = useChatStore((s) => s.isLoadingChannels);
8688
const pendingAcks = useChatStore((s) => s.pendingAcks);
@@ -89,9 +91,10 @@ export default function ChatScreen() {
8991

9092
useFocusEffect(
9193
useCallback(() => {
94+
if (!isChatEnabled) return;
9295
useChatStore.getState().fetchChannels();
9396
useChatStore.getState().fetchPendingAcks();
94-
}, [])
97+
}, [isChatEnabled])
9598
);
9699

97100
const grouped = groupChannels(channels);
@@ -103,6 +106,11 @@ export default function ChatScreen() {
103106
[router]
104107
);
105108

109+
// Chat.System feature flag off: no chat for this department.
110+
if (!isChatEnabled) {
111+
return <Redirect href={'/home' as Href} />;
112+
}
113+
106114
return (
107115
<Box className="size-full flex-1 bg-background-0">
108116
<Stack.Screen options={{ headerShown: false }} />

src/app/(app)/chatbot.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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';
@@ -19,9 +19,11 @@ import { VStack } from '@/components/ui/vstack';
1919
import { type ChatMessageResultData } from '@/models/v4/chat';
2020
import useAuthStore from '@/stores/auth/store';
2121
import { useChatStore } from '@/stores/chat/store';
22+
import { useIsChatEnabled } from '@/stores/feature-flags/store';
2223

2324
export default function ChatbotScreen() {
2425
const { t } = useTranslation();
26+
const isChatEnabled = useIsChatEnabled();
2527
const currentUserId = useAuthStore((s) => s.userId);
2628
const chatbotChannelId = useChatStore((s) => s.chatbotChannelId);
2729
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
@@ -30,19 +32,21 @@ export default function ChatbotScreen() {
3032

3133
useFocusEffect(
3234
useCallback(() => {
35+
if (!isChatEnabled) return;
3336
const store = useChatStore.getState();
3437
void store.initChatbot();
3538
return () => {
3639
useChatStore.getState().setActiveChannel(null);
3740
};
38-
}, [])
41+
}, [isChatEnabled])
3942
);
4043

4144
// Keep the assistant channel active while viewing so incoming messages don't inflate unread.
4245
useFocusEffect(
4346
useCallback(() => {
47+
if (!isChatEnabled) return;
4448
if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId);
45-
}, [chatbotChannelId])
49+
}, [chatbotChannelId, isChatEnabled])
4650
);
4751

4852
const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]);
@@ -61,6 +65,11 @@ export default function ChatbotScreen() {
6165
[currentUserId]
6266
);
6367

68+
// Chat.System feature flag off: no chat for this department.
69+
if (!isChatEnabled) {
70+
return <Redirect href={'/home' as Href} />;
71+
}
72+
6473
return (
6574
<Box className="size-full flex-1 bg-background-0">
6675
<Stack.Screen options={{ headerShown: false }} />

src/app/chat/[channelId].tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Image } from 'expo-image';
2-
import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
2+
import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
33
import { Circle } from 'lucide-react-native';
44
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
55
import { useTranslation } from 'react-i18next';
@@ -27,6 +27,7 @@ import { VStack } from '@/components/ui/vstack';
2727
import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat';
2828
import useAuthStore from '@/stores/auth/store';
2929
import { useChatStore } from '@/stores/chat/store';
30+
import { useIsChatEnabled } from '@/stores/feature-flags/store';
3031
import { securityStore } from '@/stores/security/store';
3132
import { useToastStore } from '@/stores/toast/store';
3233

@@ -38,6 +39,7 @@ export default function ChannelConversationScreen() {
3839

3940
const currentUserId = useAuthStore((s) => s.userId);
4041
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
42+
const isChatEnabled = useIsChatEnabled();
4143

4244
const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId));
4345
const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
@@ -64,7 +66,7 @@ export default function ChannelConversationScreen() {
6466
// Mount: activate channel, join hub, load history and members.
6567
useFocusEffect(
6668
useCallback(() => {
67-
if (!channelId) return;
69+
if (!channelId || !isChatEnabled) return;
6870
const store = useChatStore.getState();
6971
store.setActiveChannel(channelId);
7072
void store.joinChannel(channelId);
@@ -73,7 +75,7 @@ export default function ChannelConversationScreen() {
7375
return () => {
7476
useChatStore.getState().setActiveChannel(null);
7577
};
76-
}, [channelId])
78+
}, [channelId, isChatEnabled])
7779
);
7880

7981
// Fetch presence for the channel members (for the header online dot).
@@ -234,6 +236,11 @@ export default function ChannelConversationScreen() {
234236
if (channelId) void useChatStore.getState().loadOlderMessages(channelId);
235237
}, [channelId]);
236238

239+
// Chat.System feature flag off: no chat for this department.
240+
if (!isChatEnabled) {
241+
return <Redirect href={'/home' as Href} />;
242+
}
243+
237244
const title = channel ? getChannelDisplayName(channel, t) : t('chat.title');
238245

239246
return (

src/app/chat/thread/[messageId].tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Stack, useLocalSearchParams } from 'expo-router';
1+
import { type Href, Redirect, Stack, useLocalSearchParams } from 'expo-router';
22
import React, { useCallback, useEffect, useMemo, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import { Platform } from 'react-native';
@@ -16,25 +16,27 @@ import { logger } from '@/lib/logging';
1616
import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat';
1717
import useAuthStore from '@/stores/auth/store';
1818
import { useChatStore } from '@/stores/chat/store';
19+
import { useIsChatEnabled } from '@/stores/feature-flags/store';
1920

2021
export default function ThreadScreen() {
2122
const { t } = useTranslation();
2223
const params = useLocalSearchParams<{ messageId: string; channelId: string }>();
2324
const messageId = Array.isArray(params.messageId) ? params.messageId[0] : params.messageId;
2425
const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId;
2526

27+
const isChatEnabled = useIsChatEnabled();
2628
const currentUserId = useAuthStore((s) => s.userId);
2729
const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
2830
const [fetchedReplies, setFetchedReplies] = useState<ChatMessageResultData[]>([]);
2931

3032
const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]);
3133

3234
useEffect(() => {
33-
if (!messageId) return;
35+
if (!messageId || !isChatEnabled) return;
3436
getThread(messageId, undefined, 50)
3537
.then((response) => setFetchedReplies(response.Data ?? []))
3638
.catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } }));
37-
}, [messageId]);
39+
}, [messageId, isChatEnabled]);
3840

3941
// Merge fetched replies with any realtime/optimistic replies already in the channel cache.
4042
const replies = useMemo(() => {
@@ -96,6 +98,11 @@ export default function ThreadScreen() {
9698
[currentUserId, channelId]
9799
);
98100

101+
// Chat.System feature flag off: no chat for this department.
102+
if (!isChatEnabled) {
103+
return <Redirect href={'/home' as Href} />;
104+
}
105+
99106
return (
100107
<Box className="size-full flex-1 bg-background-0">
101108
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />

src/components/sidebar/side-menu.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import React, { useState } from 'react';
2323
import { useTranslation } from 'react-i18next';
2424
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
2525

26+
import { useIsChatEnabled } from '@/stores/feature-flags/store';
27+
2628
interface SideMenuProps {
2729
onNavigate?: () => void;
2830
colorScheme?: 'light' | 'dark';
@@ -93,7 +95,9 @@ function SideMenu({ onNavigate, colorScheme: propColorScheme }: SideMenuProps):
9395
const router = useRouter();
9496
const { t } = useTranslation();
9597
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
96-
const menuItems = getMenuItems(t);
98+
const isChatEnabled = useIsChatEnabled();
99+
// Chat and the assistant are gated by the Chat.System feature flag.
100+
const menuItems = getMenuItems(t).filter((item) => (item.id === 'chat' || item.id === 'assistant' ? isChatEnabled : true));
97101

98102
// Use prop if provided, otherwise default to light on web
99103
const isDark = propColorScheme === 'dark';

src/stores/feature-flags/store.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { create } from 'zustand';
2+
import { createJSONStorage, persist } from 'zustand/middleware';
3+
4+
import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags';
5+
import { logger } from '@/lib/logging';
6+
import { zustandStorage } from '@/lib/storage';
7+
8+
// Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys.
9+
export const FeatureFlagKeys = {
10+
ChatSystem: 'Chat.System',
11+
} as const;
12+
13+
export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys];
14+
15+
interface FeatureFlagEntry {
16+
enabled: boolean;
17+
value?: string | null;
18+
}
19+
20+
export interface FeatureFlagsState {
21+
flags: Record<string, FeatureFlagEntry>;
22+
isLoaded: boolean;
23+
error: string | null;
24+
fetchFlags: () => Promise<void>;
25+
isEnabled: (key: string, defaultValue?: boolean) => boolean;
26+
}
27+
28+
export const featureFlagsStore = create<FeatureFlagsState>()(
29+
persist(
30+
(set, get) => ({
31+
flags: {},
32+
isLoaded: false,
33+
error: null,
34+
fetchFlags: async () => {
35+
try {
36+
const response = await getAllFeatureFlags();
37+
const flags: Record<string, FeatureFlagEntry> = {};
38+
for (const flag of response?.Data ?? []) {
39+
if (flag?.Key) {
40+
flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null };
41+
}
42+
}
43+
set({ flags, isLoaded: true, error: null });
44+
} catch (error) {
45+
// Keep any persisted flags on failure so gating stays stable while offline.
46+
logger.error({
47+
message: 'Failed to fetch feature flags',
48+
context: { error },
49+
});
50+
set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags' });
51+
}
52+
},
53+
isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue,
54+
}),
55+
{
56+
name: 'feature-flags-storage',
57+
storage: createJSONStorage(() => zustandStorage),
58+
}
59+
)
60+
);
61+
62+
// Reactive hook; components re-render when the flag changes. Unknown flags default to disabled
63+
// so gated features stay hidden until the server confirms them.
64+
export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue);
65+
66+
export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem);

0 commit comments

Comments
 (0)