Skip to content

Commit 7de9c6f

Browse files
authored
Merge pull request #124 from Resgrid/develop
Develop
2 parents 565fbcd + 3ef1a93 commit 7de9c6f

41 files changed

Lines changed: 837 additions & 131 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/audio-stream-refactoring.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@ await setAudioModeAsync({
4242
allowsRecording: false,
4343
shouldPlayInBackground: true,
4444
playsInSilentMode: true,
45-
interruptionMode: 'duckOthers',
45+
interruptionMode: 'doNotMix',
4646
shouldRouteThroughEarpiece: false,
4747
});
4848
```
4949

50+
`interruptionMode` must be `'doNotMix'` for the OS to associate lock screen controls with the player.
51+
The store calls `player.setActiveForLockScreen(true, { title: stream.Name })` before `play()` and
52+
`player.clearLockScreenControls()` in `stopStream()` — without the lock screen session, Android stops
53+
background playback after roughly three minutes.
54+
5055
| expo-av | expo-audio |
5156
| --- | --- |
5257
| `allowsRecordingIOS` | `allowsRecording` |

global.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,31 @@
695695
--shadow-soft-4: 0px 0px 40px rgba(38, 38, 38, 0.1);
696696
}
697697

698+
699+
/* ─── Web-only keyframes ──────────────────────────────────────────
700+
react-native-web has no native animated module, so every RN
701+
`Animated` value is driven from JS at 60fps and writes inline
702+
styles to the DOM each frame. Long-running loops (marquees,
703+
pulses) are handed to CSS instead so they run on the compositor
704+
and cost the main thread nothing. */
705+
@keyframes skeleton-pulse {
706+
0%,
707+
100% {
708+
opacity: 1;
709+
}
710+
50% {
711+
opacity: 0.75;
712+
}
713+
}
714+
715+
/* The marquee track holds two identical halves, so translating it by
716+
-50% of its own width advances exactly one half and lands back on a
717+
pixel-identical frame -- seamless without measuring anything in JS. */
718+
@keyframes dispatch-marquee {
719+
from {
720+
transform: translateX(0);
721+
}
722+
to {
723+
transform: translateX(-50%);
724+
}
725+
}

jest-setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ jest.mock('expo-audio', () => ({
1515
replace: jest.fn(),
1616
seekTo: jest.fn(),
1717
addListener: jest.fn(() => ({ remove: jest.fn() })),
18+
setActiveForLockScreen: jest.fn(),
19+
clearLockScreenControls: jest.fn(),
1820
playing: false,
1921
paused: false,
2022
isLoaded: true,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { type AxiosError, type AxiosRequestConfig } from 'axios';
2+
3+
import useAuthStore from '@/stores/auth/store';
4+
5+
jest.mock('@/lib/logging', () => ({
6+
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
7+
}));
8+
9+
jest.mock('@/lib/storage/app', () => ({
10+
getBaseApiUrl: () => 'https://example.test/api/v4',
11+
}));
12+
13+
jest.mock('@/stores/auth/store', () => ({
14+
__esModule: true,
15+
default: {
16+
getState: jest.fn(),
17+
},
18+
}));
19+
20+
const mockRefreshAccessToken = jest.fn();
21+
const getState = (useAuthStore as unknown as { getState: jest.Mock }).getState;
22+
23+
// eslint-disable-next-line @typescript-eslint/no-require-imports
24+
const { api } = require('../client') as typeof import('../client');
25+
26+
const unauthorized = (config: AxiosRequestConfig): AxiosError => {
27+
const error = new Error('Request failed with status code 401') as AxiosError;
28+
error.isAxiosError = true;
29+
error.config = config as AxiosError['config'];
30+
error.response = { status: 401, statusText: 'Unauthorized', data: {}, headers: {}, config: config as never };
31+
return error;
32+
};
33+
34+
describe('api client 401 handling', () => {
35+
let adapterCalls: number;
36+
37+
beforeEach(() => {
38+
adapterCalls = 0;
39+
mockRefreshAccessToken.mockReset();
40+
getState.mockReset();
41+
api.defaults.adapter = async (config) => {
42+
adapterCalls += 1;
43+
throw unauthorized(config);
44+
};
45+
});
46+
47+
it('does not attempt a refresh once the refresh token is gone', async () => {
48+
getState.mockReturnValue({
49+
accessToken: null,
50+
refreshToken: null,
51+
refreshAccessToken: mockRefreshAccessToken,
52+
});
53+
54+
await expect(api.get('/Calls/GetActiveCalls')).rejects.toMatchObject({ response: { status: 401 } });
55+
56+
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
57+
// The original request only -- no retry, no doomed refresh round-trip.
58+
expect(adapterCalls).toBe(1);
59+
});
60+
61+
it('collapses a serial burst of 401s into a single refresh attempt', async () => {
62+
// Mirrors the startup chain: each store awaits the previous one, so the in-flight
63+
// dedupe never sees them overlap. The first 401 spends the refresh token; the rest
64+
// must fail fast rather than replay the refresh once each.
65+
let refreshToken: string | null = 'refresh-token';
66+
mockRefreshAccessToken.mockImplementation(async () => {
67+
refreshToken = null; // refresh failed -> store cleared the session
68+
});
69+
getState.mockImplementation(() => ({
70+
accessToken: null,
71+
refreshToken,
72+
refreshAccessToken: mockRefreshAccessToken,
73+
}));
74+
75+
const endpoints = ['/Calls/GetActiveCalls', '/Security/GetCurrentUsersRights', '/WeatherAlerts/GetSettings', '/WeatherAlerts/GetActiveAlerts'];
76+
for (const endpoint of endpoints) {
77+
await expect(api.get(endpoint)).rejects.toBeDefined();
78+
}
79+
80+
expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1);
81+
expect(adapterCalls).toBe(4);
82+
});
83+
84+
it('refreshes and retries when a token is available', async () => {
85+
getState.mockReturnValue({
86+
accessToken: 'stale-token',
87+
refreshToken: 'refresh-token',
88+
refreshAccessToken: mockRefreshAccessToken,
89+
});
90+
mockRefreshAccessToken.mockImplementation(async () => {
91+
getState.mockReturnValue({
92+
accessToken: 'fresh-token',
93+
refreshToken: 'refresh-token',
94+
refreshAccessToken: mockRefreshAccessToken,
95+
});
96+
});
97+
98+
api.defaults.adapter = async (config) => {
99+
adapterCalls += 1;
100+
if (adapterCalls === 1) {
101+
throw unauthorized(config);
102+
}
103+
return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config } as never;
104+
};
105+
106+
await expect(api.get('/Calls/GetActiveCalls')).resolves.toMatchObject({ status: 200 });
107+
108+
expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1);
109+
expect(adapterCalls).toBe(2);
110+
});
111+
});

src/api/common/client.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ axiosInstance.interceptors.response.use(
6868
});
6969
}
7070

71+
// Once a refresh has definitively failed the auth store drops the refresh token, so there is
72+
// nothing left to refresh with. The in-flight guard above only collapses *concurrent* callers;
73+
// the startup chain awaits each store in turn, so without this every one of them would fire
74+
// its own doomed refresh round-trip and its own logout. Fail fast with the original 401.
75+
if (!useAuthStore.getState().refreshToken) {
76+
return Promise.reject(error);
77+
}
78+
7179
// Add _retry property to request config type
7280
(originalRequest as InternalAxiosRequestConfig & { _retry: boolean })._retry = true;
7381
isRefreshing = true;

src/app/(app)/map.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,9 @@ export default function Map() {
260260
}, [isMapReady, isAuthenticated, isInitialized, isActive, hasUserMovedMap, location.isMapLocked]);
261261

262262
useEffect(() => {
263-
Animated.loop(
263+
// Held in a local so the loop can be stopped on unmount -- without that it keeps running (and
264+
// on web, keeps a JS rAF loop burning) for the rest of the session after leaving this screen.
265+
const pulse = Animated.loop(
264266
Animated.sequence([
265267
Animated.timing(pulseAnim, {
266268
toValue: 1.2,
@@ -273,7 +275,13 @@ export default function Map() {
273275
useNativeDriver: true,
274276
}),
275277
])
276-
).start();
278+
);
279+
280+
pulse.start();
281+
282+
return () => {
283+
pulse.stop();
284+
};
277285
}, [pulseAnim]);
278286

279287
// Track when map view is rendered

src/app/_layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { PushNotificationModal } from '@/components/push-notification/push-notif
2424
import { ToastContainer } from '@/components/toast/toast-container';
2525
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
2626
import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
27-
import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme';
27+
import { loadSelectedTheme, useSelectedTheme } from '@/lib/hooks/use-selected-theme';
2828
import { logger } from '@/lib/logging';
2929
import { sentryService } from '@/lib/sentry';
3030
import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app';
@@ -210,11 +210,17 @@ function RootLayout() {
210210
}
211211

212212
function Providers({ children }: { children: React.ReactNode }) {
213-
const colorScheme = useColorScheme();
213+
const osScheme = useColorScheme();
214+
// The stored preference is the source of truth. On native it also lands in
215+
// Appearance, so useColorScheme() alone would do -- but web has no Appearance
216+
// override, and the web GluestackUIProvider writes the <html> class straight
217+
// from `mode`, so feeding it the OS scheme would undo the selected theme.
218+
const { selectedTheme } = useSelectedTheme();
219+
const colorScheme: 'light' | 'dark' = selectedTheme === 'system' ? (osScheme === 'dark' ? 'dark' : 'light') : selectedTheme;
214220

215221
const renderContent = () => (
216222
<APIProvider>
217-
<GluestackUIProvider mode={(colorScheme ?? 'light') as 'light' | 'dark'}>
223+
<GluestackUIProvider mode={colorScheme}>
218224
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
219225
<BottomSheetModalProvider>
220226
{children}

src/components/calls/auto-scrolling-dispatches.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,37 @@ const DISPATCH_TYPE_COLORS: Record<string, string> = {
3030

3131
const SEPARATOR_WIDTH = 40;
3232

33+
const IS_WEB = Platform.OS === 'web';
34+
3335
export const AutoScrollingDispatches: React.FC<AutoScrollingDispatchesProps> = ({ dispatches, resolveDisplayName, scrollSpeed, fontSize }) => {
3436
const scrollX = useRef(new Animated.Value(0)).current;
3537
const animRef = useRef<Animated.CompositeAnimation | null>(null);
3638
const [primaryWidth, setPrimaryWidth] = useState(0);
3739

40+
const loopWidth = primaryWidth + SEPARATOR_WIDTH;
41+
const isScrolling = scrollSpeed > 0 && primaryWidth > 0;
42+
const durationMs = isScrolling ? (loopWidth / scrollSpeed) * 1000 : 0;
43+
3844
useEffect(() => {
3945
// Stop any running animation and reset position whenever deps change
4046
animRef.current?.stop();
4147
animRef.current = null;
4248
scrollX.setValue(0);
4349

50+
// Web drives this from CSS instead -- see the track style below. react-native-web has no
51+
// native animated module, so the JS path here would repaint from a 60fps rAF loop, and one
52+
// of these renders per dispatched call.
53+
if (IS_WEB) {
54+
return;
55+
}
56+
4457
if (scrollSpeed <= 0 || primaryWidth <= 0) {
4558
return;
4659
}
4760

4861
// Scroll the whole loopWidth (primary + gap) — the duplicate copy placed right
4962
// after fills the gap so the transition is seamless.
50-
const loopWidth = primaryWidth + SEPARATOR_WIDTH;
51-
const duration = (loopWidth / scrollSpeed) * 1000;
63+
const duration = durationMs;
5264

5365
let active = true;
5466

@@ -62,7 +74,7 @@ export const AutoScrollingDispatches: React.FC<AutoScrollingDispatchesProps> = (
6274
const timing = Animated.timing(scrollX, {
6375
toValue: -loopWidth,
6476
duration,
65-
useNativeDriver: Platform.OS !== 'web',
77+
useNativeDriver: true,
6678
isInteraction: false,
6779
});
6880
animRef.current = timing;
@@ -78,7 +90,7 @@ export const AutoScrollingDispatches: React.FC<AutoScrollingDispatchesProps> = (
7890
animRef.current?.stop();
7991
animRef.current = null;
8092
};
81-
}, [primaryWidth, scrollSpeed, scrollX]);
93+
}, [primaryWidth, scrollSpeed, scrollX, durationMs, loopWidth]);
8294

8395
if (dispatches.length === 0) return null;
8496

@@ -107,9 +119,17 @@ export const AutoScrollingDispatches: React.FC<AutoScrollingDispatchesProps> = (
107119
);
108120
});
109121

122+
// On web the whole track is one compositor-driven CSS animation, so it renders as a plain View
123+
// with no Animated wrapper and no per-frame JS. -50% of the track equals one half, which is why
124+
// the duplicate copy below gets its own trailing separator on web: the two halves must match.
125+
const Track = IS_WEB ? View : Animated.View;
126+
const trackStyle = IS_WEB
127+
? [styles.row, isScrolling ? ({ animation: `dispatch-marquee ${durationMs}ms linear infinite`, willChange: 'transform' } as never) : null]
128+
: [styles.row, { transform: [{ translateX: scrollX }] }];
129+
110130
return (
111131
<View style={styles.container}>
112-
<Animated.View style={[styles.row, { transform: [{ translateX: scrollX }] }]}>
132+
<Track style={trackStyle}>
113133
{/* Primary copy — measure width to drive the animation */}
114134
<View
115135
testID="auto-scroll-primary"
@@ -129,9 +149,11 @@ export const AutoScrollingDispatches: React.FC<AutoScrollingDispatchesProps> = (
129149
<View style={styles.row} accessible={false} accessibilityElementsHidden={true} importantForAccessibility="no-hide-descendants">
130150
{renderChips(dispatches, 'b')}
131151
</View>
152+
{/* Trailing gap so the two halves are identical and -50% lands seamlessly (web only) */}
153+
{IS_WEB ? <View style={{ width: SEPARATOR_WIDTH, flexShrink: 0 }} accessible={false} /> : null}
132154
</>
133155
) : null}
134-
</Animated.View>
156+
</Track>
135157
</View>
136158
);
137159
};

src/components/widgets/CallsSummaryWidget.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ interface CallsWidgetProps {
2121
export const CallsSummaryWidget: React.FC<CallsWidgetProps> = ({ onRemove, isEditMode, width = 2, height = 2 }) => {
2222
const { colorScheme } = useColorScheme();
2323
const isDark = colorScheme === 'dark';
24-
const { calls, callPriorities, isLoading, error, init } = useCallsStore();
25-
const { callsSummary } = useWidgetSettingsStore();
24+
const calls = useCallsStore((state) => state.calls);
25+
const callPriorities = useCallsStore((state) => state.callPriorities);
26+
const isLoading = useCallsStore((state) => state.isLoading);
27+
const error = useCallsStore((state) => state.error);
28+
const init = useCallsStore((state) => state.init);
29+
const callsSummary = useWidgetSettingsStore((state) => state.callsSummary);
2630

2731
// Enable real-time updates via SignalR
2832
useCallsSignalRUpdates();

src/components/widgets/CallsWidget.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,17 @@ interface CallsWidgetProps {
3232
export const CallsWidget: React.FC<CallsWidgetProps> = ({ onRemove, isEditMode, containerWidth, containerHeight }) => {
3333
const { colorScheme } = useColorScheme();
3434
const isDark = colorScheme === 'dark';
35-
const { calls, callPriorities, callExtraDataMap, isLoading, error, init } = useCallsStore();
36-
const { settings } = useCallsSettingsStore();
37-
const { personnel, init: initPersonnel } = usePersonnelStore();
38-
const { units, fetchUnits } = useUnitsStore();
35+
const calls = useCallsStore((state) => state.calls);
36+
const callPriorities = useCallsStore((state) => state.callPriorities);
37+
const callExtraDataMap = useCallsStore((state) => state.callExtraDataMap);
38+
const isLoading = useCallsStore((state) => state.isLoading);
39+
const error = useCallsStore((state) => state.error);
40+
const init = useCallsStore((state) => state.init);
41+
const settings = useCallsSettingsStore((state) => state.settings);
42+
const personnel = usePersonnelStore((state) => state.personnel);
43+
const initPersonnel = usePersonnelStore((state) => state.init);
44+
const units = useUnitsStore((state) => state.units);
45+
const fetchUnits = useUnitsStore((state) => state.fetchUnits);
3946
const [groups, setGroups] = useState<GroupResultData[]>([]);
4047
const [roles, setRoles] = useState<RecipientsResultData[]>([]);
4148

0 commit comments

Comments
 (0)