diff --git a/.eslintignore b/.eslintignore
index 85e70a7c..80268680 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,4 +1,4 @@
-.eslintignorenode_modules
+node_modules
__tests__/
.vscode/
android/
@@ -7,4 +7,9 @@ ios/
.expo
.expo-shared
docs/
-cli/
\ No newline at end of file
+cli/
+electron/
+fastlane/
+patches/
+public/
+scripts/
diff --git a/.eslintrc.js b/.eslintrc.js
index 37387a72..96a6bed9 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,11 +1,11 @@
const path = require('path');
module.exports = {
- extends: ['expo', 'plugin:tailwindcss/recommended', 'prettier'],
- plugins: ['prettier', 'unicorn', '@typescript-eslint', 'unused-imports', 'tailwindcss', 'simple-import-sort', 'eslint-plugin-react-compiler'],
- parserOptions: {
- project: './tsconfig.json',
- },
+ extends: ['expo', 'prettier'],
+ plugins: ['prettier', 'unicorn', '@typescript-eslint', 'unused-imports', 'simple-import-sort', 'eslint-plugin-react-compiler'],
+ // parserOptions: {
+ // project: './tsconfig.json',
+ // },
rules: {
'prettier/prettier': 'warn',
'max-params': ['error', 10], // Limit the number of parameters in a function to use object instead
@@ -24,17 +24,10 @@ module.exports = {
},
], // Ensure `import type` is used when it's necessary
'import/prefer-default-export': 'off', // Named export is easier to refactor automatically
- 'import/no-cycle': ['error', { maxDepth: '∞' }],
- 'tailwindcss/classnames-order': [
- 'warn',
- {
- officialSorting: true,
- },
- ], // Follow the same ordering as the official plugin `prettier-plugin-tailwindcss`
+ 'import/no-cycle': 'off', // Disabled due to performance issues
'simple-import-sort/imports': 'error', // Import configuration for `eslint-plugin-simple-import-sort`
'simple-import-sort/exports': 'error', // Export configuration for `eslint-plugin-simple-import-sort`
'@typescript-eslint/no-unused-vars': 'off',
- 'tailwindcss/no-custom-classname': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': [
'off',
diff --git a/Dockerfile b/Dockerfile
index fae6bcd6..f27e0a97 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,9 +15,10 @@ RUN yarn install --frozen-lockfile
# Copy source files
COPY . .
-# Build the web application without environment variables
-# Environment variables will be injected at runtime via docker-entrypoint.sh
-RUN yarn web:build
+# Build the web application with production defaults
+# Runtime environment variables will be injected at startup via docker-entrypoint.sh
+# APP_ENV=production ensures the build uses production defaults and no .env suffix on IDs
+RUN APP_ENV=production yarn web:build
### STAGE 2: Run ###
FROM nginx:1.25-alpine
@@ -42,6 +43,8 @@ EXPOSE 80
ENV APP_ENV=production \
UNIT_NAME="Resgrid Unit" \
UNIT_SCHEME="ResgridUnit" \
+ UNIT_BUNDLE_ID="com.resgrid.unit" \
+ UNIT_PACKAGE="com.resgrid.unit" \
UNIT_VERSION="0.0.1" \
UNIT_BASE_API_URL="https://api.resgrid.com" \
UNIT_API_VERSION="v4" \
diff --git a/__mocks__/react-native-svg.ts b/__mocks__/react-native-svg.ts
new file mode 100644
index 00000000..8faa439b
--- /dev/null
+++ b/__mocks__/react-native-svg.ts
@@ -0,0 +1,43 @@
+import React from 'react';
+
+const createMockComponent = (name: string) => {
+ const Component = React.forwardRef((props: any, ref: any) =>
+ React.createElement(name, { ...props, ref })
+ );
+ Component.displayName = name;
+ return Component;
+};
+
+export const Svg = createMockComponent('Svg');
+export const Circle = createMockComponent('Circle');
+export const Ellipse = createMockComponent('Ellipse');
+export const G = createMockComponent('G');
+export const Text = createMockComponent('SvgText');
+export const TSpan = createMockComponent('TSpan');
+export const TextPath = createMockComponent('TextPath');
+export const Path = createMockComponent('Path');
+export const Polygon = createMockComponent('Polygon');
+export const Polyline = createMockComponent('Polyline');
+export const Line = createMockComponent('Line');
+export const Rect = createMockComponent('Rect');
+export const Use = createMockComponent('Use');
+export const Image = createMockComponent('SvgImage');
+export const Symbol = createMockComponent('SvgSymbol');
+export const Defs = createMockComponent('Defs');
+export const LinearGradient = createMockComponent('LinearGradient');
+export const RadialGradient = createMockComponent('RadialGradient');
+export const Stop = createMockComponent('Stop');
+export const ClipPath = createMockComponent('ClipPath');
+export const Pattern = createMockComponent('Pattern');
+export const Mask = createMockComponent('Mask');
+export const ForeignObject = createMockComponent('ForeignObject');
+export const Marker = createMockComponent('Marker');
+export const SvgFromUri = createMockComponent('SvgFromUri');
+export const SvgFromXml = createMockComponent('SvgFromXml');
+export const SvgXml = createMockComponent('SvgXml');
+export const SvgUri = createMockComponent('SvgUri');
+export const SvgCss = createMockComponent('SvgCss');
+export const SvgCssUri = createMockComponent('SvgCssUri');
+export const parse = jest.fn();
+
+export default Svg;
diff --git a/app.config.ts b/app.config.ts
index 0ef1f958..042b20df 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -78,9 +78,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'android.permission.POST_NOTIFICATIONS',
'android.permission.FOREGROUND_SERVICE',
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
+ 'android.permission.FOREGROUND_SERVICE_PHONE_CALL',
'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE',
'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
'android.permission.READ_PHONE_STATE',
+ 'android.permission.READ_PHONE_NUMBERS',
'android.permission.MANAGE_OWN_CALLS',
],
},
diff --git a/babel.config.js b/babel.config.js
index 5d7d5cb8..42f8cb37 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -25,6 +25,7 @@ module.exports = function (api) {
extensions: ['.ios.ts', '.android.ts', '.ts', '.ios.tsx', '.android.tsx', '.tsx', '.jsx', '.js', '.json'],
},
],
+ 'babel-plugin-transform-import-meta',
'react-native-reanimated/plugin',
],
};
diff --git a/docker-compose.yml b/docker-compose.yml
index ba2e92f3..bfd9ae6a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,8 @@ services:
- APP_ENV=production
- UNIT_NAME=Resgrid Unit
- UNIT_SCHEME=ResgridUnit
+ - UNIT_BUNDLE_ID=com.resgrid.unit
+ - UNIT_PACKAGE=com.resgrid.unit
- UNIT_VERSION=0.0.1
- UNIT_BASE_API_URL=https://api.resgrid.com
- UNIT_API_VERSION=v4
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index 9d8f2824..29e5dc85 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -11,6 +11,7 @@ js_escape() {
}
# Create the env-config.js file with environment variables
+# Includes ALL fields expected by the client env schema in env.js
cat > "${HTML_DIR}/env-config.js" << EOF
// Runtime environment configuration - generated by docker-entrypoint.sh
// This file is generated at container startup and injects environment variables
@@ -18,7 +19,10 @@ window.__ENV__ = {
APP_ENV: "$(js_escape "${APP_ENV:-production}")",
NAME: "$(js_escape "${UNIT_NAME:-Resgrid Unit}")",
SCHEME: "$(js_escape "${UNIT_SCHEME:-ResgridUnit}")",
+ BUNDLE_ID: "$(js_escape "${UNIT_BUNDLE_ID:-com.resgrid.unit}")",
+ PACKAGE: "$(js_escape "${UNIT_PACKAGE:-com.resgrid.unit}")",
VERSION: "$(js_escape "${UNIT_VERSION:-0.0.1}")",
+ ANDROID_VERSION_CODE: 1,
BASE_API_URL: "$(js_escape "${UNIT_BASE_API_URL:-https://api.resgrid.com}")",
API_VERSION: "$(js_escape "${UNIT_API_VERSION:-v4}")",
RESGRID_API_URL: "$(js_escape "${UNIT_RESGRID_API_URL:-/api/v4}")",
diff --git a/electron/main.js b/electron/main.js
index a3c51d00..c5957f9b 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -1,6 +1,24 @@
/* eslint-disable no-undef */
-const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu } = require('electron');
+const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu, protocol, net } = require('electron');
const path = require('path');
+const fs = require('fs');
+const { pathToFileURL } = require('url');
+
+// Register custom protocol scheme before app is ready
+// This allows serving the Expo web export with absolute paths (/_expo/static/...)
+// via a custom protocol instead of file://, which breaks absolute path resolution.
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: 'app',
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ stream: true,
+ },
+ },
+]);
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
@@ -33,9 +51,14 @@ function createWindow() {
});
// Load the app
- const startUrl = isDev ? 'http://localhost:8081' : `file://${path.join(__dirname, '../dist/index.html')}`;
-
- mainWindow.loadURL(startUrl);
+ if (isDev) {
+ // In development, load from the Expo dev server
+ mainWindow.loadURL('http://localhost:8081');
+ } else {
+ // In production, load via the custom app:// protocol
+ // which correctly resolves absolute paths (/_expo/static/...) from the dist directory
+ mainWindow.loadURL('app://bundle/index.html');
+ }
// Show window when ready
mainWindow.once('ready-to-show', () => {
@@ -154,6 +177,41 @@ ipcMain.handle('get-platform', () => {
// Handle app ready
app.whenReady().then(() => {
+ // Register custom protocol handler for serving the Expo web export
+ // This resolves absolute paths like /_expo/static/js/... from the dist directory
+ const distPath = path.join(__dirname, '..', 'dist');
+ const resolvedDist = path.resolve(distPath);
+
+ protocol.handle('app', (request) => {
+ const url = new URL(request.url);
+ // Decode the pathname, join with base path, then canonicalize to prevent directory traversal
+ const joinedPath = path.join(distPath, decodeURIComponent(url.pathname));
+ const resolvedPath = path.resolve(joinedPath);
+
+ // Security check: ensure resolved path is within distPath to prevent directory traversal
+ let filePath;
+ if (!resolvedPath.startsWith(resolvedDist + path.sep) && resolvedPath !== resolvedDist) {
+ // Path escapes distPath - fall back to index.html
+ filePath = path.join(resolvedDist, 'index.html');
+ } else {
+ filePath = resolvedPath;
+
+ // If the path points to a directory or file doesn't exist, fall back to index.html
+ // This supports SPA client-side routing
+ try {
+ const stat = fs.statSync(filePath);
+ if (stat.isDirectory()) {
+ filePath = path.join(resolvedDist, 'index.html');
+ }
+ } catch {
+ // File not found - serve index.html for client-side routing
+ filePath = path.join(resolvedDist, 'index.html');
+ }
+ }
+
+ return net.fetch(pathToFileURL(filePath).toString());
+ });
+
createMenu();
createWindow();
diff --git a/global.css b/global.css
index b5c61c95..eb9f4e72 100644
--- a/global.css
+++ b/global.css
@@ -1,3 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+/* Web-only pulse animation for user location marker on map */
+@keyframes pulse-ring {
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 0.3;
+ }
+ 50% {
+ transform: scale(1.2);
+ opacity: 0.15;
+ }
+}
+
+/* Web-only skeleton loading animation */
+@keyframes skeleton-pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.75;
+ }
+}
diff --git a/jest-setup.ts b/jest-setup.ts
index 9274f931..f4c053b5 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -6,6 +6,34 @@ global.window = {};
// @ts-ignore
global.window = global;
+// Mock react-native Platform globally
+// Must include `default` export because react-native/index.js and processColor.js
+// access Platform via require('./Libraries/Utilities/Platform').default
+jest.mock('react-native/Libraries/Utilities/Platform', () => {
+ const platform = {
+ OS: 'ios' as const,
+ select: jest.fn((obj: any) => obj.ios ?? obj.native ?? obj.default),
+ Version: 14,
+ constants: {
+ osVersion: '14.0',
+ interfaceIdiom: 'phone',
+ isTesting: true,
+ reactNativeVersion: { major: 0, minor: 76, patch: 0 },
+ systemName: 'iOS',
+ },
+ isTesting: true,
+ isPad: false,
+ isTV: false,
+ isVision: false,
+ isMacCatalyst: false,
+ };
+ return {
+ __esModule: true,
+ default: platform,
+ ...platform,
+ };
+});
+
// Mock expo-audio globally
jest.mock('expo-audio', () => ({
createAudioPlayer: jest.fn(() => ({
diff --git a/metro.config.js b/metro.config.js
index 12a6bf86..3eaa5b88 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -42,6 +42,52 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
type: 'sourceFile',
};
}
+
+ // Countly SDK needs its own shim with proper default export.
+ // The CountlyConfig subpath must resolve to a dedicated shim whose
+ // default export is the CountlyConfig class (not the Countly object).
+ if (moduleName === 'countly-sdk-react-native-bridge/CountlyConfig') {
+ return {
+ filePath: path.resolve(__dirname, 'src/lib/countly-config-shim.web.ts'),
+ type: 'sourceFile',
+ };
+ }
+ if (moduleName === 'countly-sdk-react-native-bridge' || moduleName.startsWith('countly-sdk-react-native-bridge/')) {
+ return {
+ filePath: path.resolve(__dirname, 'src/lib/countly-shim.web.ts'),
+ type: 'sourceFile',
+ };
+ }
+
+ // Force zustand and related packages to use CJS build instead of ESM
+ // The ESM build uses import.meta.env which Metro doesn't support
+ const zustandModules = {
+ zustand: path.resolve(__dirname, 'node_modules/zustand/index.js'),
+ 'zustand/shallow': path.resolve(__dirname, 'node_modules/zustand/shallow.js'),
+ 'zustand/middleware': path.resolve(__dirname, 'node_modules/zustand/middleware.js'),
+ 'zustand/traditional': path.resolve(__dirname, 'node_modules/zustand/traditional.js'),
+ 'zustand/vanilla': path.resolve(__dirname, 'node_modules/zustand/vanilla.js'),
+ 'zustand/context': path.resolve(__dirname, 'node_modules/zustand/context.js'),
+ };
+
+ if (zustandModules[moduleName]) {
+ return {
+ filePath: zustandModules[moduleName],
+ type: 'sourceFile',
+ };
+ }
+
+ // Block build-time/dev packages that use import.meta from being bundled
+ // These are dev tools that should never be included in a client bundle
+ const buildTimePackages = ['tinyglobby', 'fdir', 'node-gyp', 'electron-builder', 'electron-rebuild', '@electron/rebuild', 'app-builder-lib', 'dmg-builder'];
+
+ if (buildTimePackages.some((pkg) => moduleName === pkg || moduleName.startsWith(`${pkg}/`))) {
+ // Return an empty module shim
+ return {
+ filePath: path.resolve(__dirname, 'src/lib/empty-module.web.js'),
+ type: 'sourceFile',
+ };
+ }
}
// Use the original resolver for everything else
diff --git a/nginx.conf b/nginx.conf
index 4ec03b0c..8e14342d 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -38,7 +38,9 @@ http {
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self';" always;
+ # Note: CSP connect-src/img-src must allow the configured API URL, Mapbox, Sentry, etc.
+ # The docker-entrypoint.sh can generate a tighter policy if needed.
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss:; worker-src 'self' blob:;" always;
# Static assets with cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
@@ -46,7 +48,7 @@ http {
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self';" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss:; worker-src 'self' blob:;" always;
try_files $uri =404;
}
diff --git a/package.json b/package.json
index ddc419a9..84bc55a0 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"build:internal:android": "cross-env APP_ENV=internal EXPO_NO_DOTENV=1 eas build --profile internal --platform android",
"postinstall": "patch-package",
"app-release": "cross-env SKIP_BRANCH_PROTECTION=true np --no-publish --no-cleanup --no-release-draft",
- "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
+ "lint": "eslint src --ext .ts,.tsx --cache --cache-location node_modules/.cache/eslint",
"type-check": "tsc --noemit",
"lint:translations": "eslint ./src/translations/ --fix --ext .json ",
"test": "jest --coverage=true --coverageReporters=cobertura",
@@ -193,6 +193,7 @@
"@typescript-eslint/eslint-plugin": "~5.62.0",
"@typescript-eslint/parser": "~5.62.0",
"babel-jest": "~30.0.0",
+ "babel-plugin-transform-import-meta": "^2.3.3",
"concurrently": "9.2.1",
"cross-env": "~7.0.3",
"dotenv": "~16.4.5",
diff --git a/scripts/patch-nativewind.py b/scripts/patch-nativewind.py
new file mode 100644
index 00000000..4ccc4e12
--- /dev/null
+++ b/scripts/patch-nativewind.py
@@ -0,0 +1,131 @@
+import os
+import sys
+
+# Determine project root: use CLI argument if provided, otherwise compute from script location
+if len(sys.argv) > 1:
+ project_root = os.path.abspath(sys.argv[1])
+else:
+ # Script is in scripts/, so go up one level to get project root
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+
+# Patch 1: Fix color-scheme.js leading-space bug
+color_scheme_path = os.path.join(
+ project_root,
+ 'node_modules', 'react-native-css-interop', 'dist', 'runtime', 'web', 'color-scheme.js'
+)
+
+with open(color_scheme_path, 'r') as f:
+ content = f.read()
+
+# Fix: trim the darkModeFlag to handle leading space from getPropertyValue
+old = 'const darkModeFlag = stylesheet_1.StyleSheet.getFlag("darkMode");'
+new = 'const darkModeFlag = (stylesheet_1.StyleSheet.getFlag("darkMode") || "").trim();'
+content = content.replace(old, new)
+
+# Fix: MutationObserver must not call colorScheme.set() when darkMode is "media"
+# because set() throws for media mode. Just record the mode and return.
+old_observer = ''' exports.colorScheme.set(globalThis.window.document.documentElement.classList.contains(darkModeValue)
+ ? "dark"
+ : "system");'''
+new_observer = ''' // For "media" mode the browser handles dark mode via CSS media queries;
+ // calling set() would throw, so just return after recording the mode.
+ if (darkMode === "media")
+ return;
+ exports.colorScheme.set(globalThis.window.document.documentElement.classList.contains(darkModeValue)
+ ? "dark"
+ : "system");'''
+content = content.replace(old_observer, new_observer)
+
+with open(color_scheme_path, 'w') as f:
+ f.write(content)
+
+print(f"Patched color-scheme.js ({content.count('.trim()')} trim calls)")
+
+# Patch 2: Fix useColorScheme.js effect memory leak
+use_color_scheme_path = os.path.join(
+ project_root,
+ 'node_modules', 'react-native-css-interop', 'dist', 'runtime', 'web', 'useColorScheme.js'
+)
+
+# Read existing file and perform targeted replacement with safety checks
+with open(use_color_scheme_path, 'r') as f:
+ use_color_scheme_content = f.read()
+
+# Verify the file matches expected structure before patching
+if 'function useColorScheme()' not in use_color_scheme_content:
+ print("ERROR: useColorScheme.js does not contain expected 'function useColorScheme()' signature.")
+ print("File may have been updated upstream. Skipping patch to avoid overwriting unexpected changes.")
+ sys.exit(1)
+
+if 'colorScheme: color_scheme_1.colorScheme.get(effect)' not in use_color_scheme_content:
+ print("ERROR: useColorScheme.js does not contain expected return structure.")
+ print("File may have been updated upstream. Skipping patch to avoid overwriting unexpected changes.")
+ sys.exit(1)
+
+# Check if already patched
+if 'cleanupEffect' in use_color_scheme_content and 'prevEffect.current' in use_color_scheme_content:
+ print("useColorScheme.js already patched (cleanupEffect logic present)")
+else:
+ # Targeted replacement: Add prevEffect cleanup logic after useState declaration
+ old_effect_pattern = '''function useColorScheme() {
+ const [effect, setEffect] = (0, react_1.useState)(() => ({
+ run: () => setEffect((s) => ({ ...s })),
+ dependencies: new Set(),
+ }));
+ return {'''
+
+ new_effect_pattern = '''function useColorScheme() {
+ const [effect, setEffect] = (0, react_1.useState)(() => ({
+ run: () => setEffect((s) => ({ ...s })),
+ dependencies: new Set(),
+ }));
+ // Clean up stale effects when a new effect object is created to prevent
+ // orphaned effects from accumulating in the observable's effects Set.
+ const prevEffect = (0, react_1.useRef)(null);
+ if (prevEffect.current && prevEffect.current !== effect) {
+ (0, observable_1.cleanupEffect)(prevEffect.current);
+ }
+ prevEffect.current = effect;
+ // Also clean up on unmount
+ (0, react_1.useEffect)(() => {
+ return () => (0, observable_1.cleanupEffect)(effect);
+ }, [effect]);
+ return {'''
+
+ if old_effect_pattern in use_color_scheme_content:
+ use_color_scheme_content = use_color_scheme_content.replace(old_effect_pattern, new_effect_pattern)
+
+ with open(use_color_scheme_path, 'w') as f:
+ f.write(use_color_scheme_content)
+
+ print("Patched useColorScheme.js (added cleanup logic)")
+ else:
+ print("ERROR: useColorScheme.js structure does not match expected pattern.")
+ print("Expected pattern not found. File may have been updated upstream.")
+ print("Skipping patch to avoid overwriting unexpected changes.")
+ sys.exit(1)
+
+# Patch 3: Fix api.js direct baseComponent.render() / baseComponent() calls
+# that break under React 19 (hooks run on wrong fiber)
+api_path = os.path.join(
+ project_root,
+ 'node_modules', 'react-native-css-interop', 'dist', 'runtime', 'web', 'api.js'
+)
+
+with open(api_path, 'r') as f:
+ api_content = f.read()
+
+api_content = api_content.replace(
+ 'return baseComponent.render(props, props.ref);',
+ 'return (0, react_1.createElement)(baseComponent, props);'
+)
+api_content = api_content.replace(
+ 'return baseComponent(props);',
+ 'return (0, react_1.createElement)(baseComponent, props);'
+)
+
+with open(api_path, 'w') as f:
+ f.write(api_content)
+
+print("Patched api.js (createElement instead of direct calls)")
+print("Done!")
diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx
index 21656f3f..1f1daf35 100644
--- a/src/app/(app)/__tests__/calls.test.tsx
+++ b/src/app/(app)/__tests__/calls.test.tsx
@@ -65,7 +65,7 @@ const mockAnalytics = {
// Mock the stores with proper getState method
jest.mock('@/stores/calls/store', () => {
- const useCallsStore = jest.fn(() => mockCallsStore);
+ const useCallsStore = jest.fn((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
(useCallsStore as any).getState = jest.fn(() => mockCallsStore);
return {
@@ -74,7 +74,8 @@ jest.mock('@/stores/calls/store', () => {
});
jest.mock('@/stores/security/store', () => ({
- useSecurityStore: jest.fn(() => mockSecurityStore),
+ securityStore: jest.fn(),
+ useSecurityStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore),
}));
jest.mock('@/hooks/use-analytics', () => ({
@@ -209,17 +210,30 @@ import CallsScreen from '../calls';
describe('CallsScreen', () => {
const { useCallsStore } = require('@/stores/calls/store');
- const { useSecurityStore } = require('@/stores/security/store');
+ const { securityStore, useSecurityStore } = require('@/stores/security/store');
const { useAnalytics } = require('@/hooks/use-analytics');
beforeEach(() => {
jest.clearAllMocks();
// Reset mock returns to defaults
- useCallsStore.mockReturnValue(mockCallsStore);
- useSecurityStore.mockReturnValue(mockSecurityStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
useAnalytics.mockReturnValue(mockAnalytics);
+ // Setup securityStore as a selector-based store
+ securityStore.mockImplementation((selector: any) => {
+ const state = {
+ rights: {
+ CanCreateCalls: mockSecurityStore.canUserCreateCalls,
+ },
+ };
+ if (selector) {
+ return selector(state);
+ }
+ return state;
+ });
+
// Reset the mock store state
mockCallsStore.calls = [];
mockCallsStore.isLoading = false;
@@ -232,7 +246,7 @@ describe('CallsScreen', () => {
describe('when user has create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = true;
- useSecurityStore.mockReturnValue(mockSecurityStore);
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
});
it('renders the new call FAB button', () => {
@@ -262,7 +276,7 @@ describe('CallsScreen', () => {
describe('when user does not have create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = false;
- useSecurityStore.mockReturnValue(mockSecurityStore);
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
});
it('does not render the new call FAB button', () => {
@@ -290,7 +304,7 @@ describe('CallsScreen', () => {
beforeEach(() => {
mockCallsStore.calls = mockCalls;
- useCallsStore.mockReturnValue(mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
});
it('renders call cards for each call', () => {
@@ -332,7 +346,7 @@ describe('CallsScreen', () => {
describe('loading and error states', () => {
it('shows loading state when isLoading is true', () => {
mockCallsStore.isLoading = true;
- useCallsStore.mockReturnValue(mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
render();
@@ -345,7 +359,7 @@ describe('CallsScreen', () => {
it('shows error state when there is an error', () => {
mockCallsStore.error = 'Network error';
- useCallsStore.mockReturnValue(mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
render();
@@ -358,7 +372,7 @@ describe('CallsScreen', () => {
it('shows zero state when there are no calls', () => {
mockCallsStore.calls = [];
- useCallsStore.mockReturnValue(mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
render();
@@ -382,7 +396,7 @@ describe('CallsScreen', () => {
it('tracks view rendered event with correct parameters', () => {
const mockCalls = [{ CallId: 'call-1', Nature: 'Test' }];
mockCallsStore.calls = mockCalls;
- useCallsStore.mockReturnValue(mockCallsStore);
+ useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore);
render();
diff --git a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx b/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx
index 4bfacec9..21e91f4e 100644
--- a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx
+++ b/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx
@@ -107,7 +107,14 @@ describe('Contacts Pull-to-Refresh Integration', () => {
it('should properly configure pull-to-refresh with force cache refresh', async () => {
const mockFetchContacts = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: mockFetchContacts,
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -135,7 +142,14 @@ describe('Contacts Pull-to-Refresh Integration', () => {
it('should maintain refresh state correctly during pull-to-refresh', async () => {
const mockFetchContacts = jest.fn().mockImplementation(() => Promise.resolve());
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: mockFetchContacts,
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -163,7 +177,14 @@ describe('Contacts Pull-to-Refresh Integration', () => {
it('should show proper loading states during refresh vs initial load', () => {
// Test initial loading state
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [],
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: true,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: [],
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -178,7 +199,14 @@ describe('Contacts Pull-to-Refresh Integration', () => {
expect(getByText('Loading')).toBeTruthy();
// Test refresh loading state (with existing contacts)
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: true, // Loading is true but contacts exist
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
diff --git a/src/app/(app)/__tests__/contacts.test.tsx b/src/app/(app)/__tests__/contacts.test.tsx
index c698c5c3..0c4f93a5 100644
--- a/src/app/(app)/__tests__/contacts.test.tsx
+++ b/src/app/(app)/__tests__/contacts.test.tsx
@@ -120,7 +120,14 @@ describe('Contacts Page', () => {
});
it('should render loading state during initial fetch', () => {
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [],
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: true,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: [],
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -139,7 +146,14 @@ describe('Contacts Page', () => {
const mockSelectContact = jest.fn();
const mockSetSearchQuery = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ selectContact: mockSelectContact,
+ isLoading: false,
+ fetchContacts: mockFetchContacts,
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: mockSetSearchQuery,
@@ -160,7 +174,14 @@ describe('Contacts Page', () => {
});
it('should render zero state when no contacts are available', () => {
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [],
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: [],
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -177,7 +198,14 @@ describe('Contacts Page', () => {
it('should filter contacts based on search query', async () => {
const mockSetSearchQuery = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: 'john',
+ setSearchQuery: mockSetSearchQuery,
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: 'john',
setSearchQuery: mockSetSearchQuery,
@@ -197,7 +225,14 @@ describe('Contacts Page', () => {
});
it('should show zero state when search returns no results', () => {
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: 'nonexistent',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: 'nonexistent',
setSearchQuery: jest.fn(),
@@ -214,7 +249,14 @@ describe('Contacts Page', () => {
it('should handle search input changes', async () => {
const mockSetSearchQuery = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: mockSetSearchQuery,
@@ -234,7 +276,14 @@ describe('Contacts Page', () => {
it('should clear search query when X button is pressed', async () => {
const mockSetSearchQuery = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: 'john',
+ setSearchQuery: mockSetSearchQuery,
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: 'john',
setSearchQuery: mockSetSearchQuery,
@@ -258,7 +307,14 @@ describe('Contacts Page', () => {
it('should handle contact selection', async () => {
const mockSelectContact = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: mockSelectContact,
+ isLoading: false,
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -278,7 +334,14 @@ describe('Contacts Page', () => {
it('should handle refresh functionality', async () => {
const mockFetchContacts = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: mockFetchContacts,
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -302,7 +365,14 @@ describe('Contacts Page', () => {
it('should call fetchContacts with force refresh when pulling to refresh', async () => {
const mockFetchContacts = jest.fn();
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: false,
+ fetchContacts: mockFetchContacts,
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
@@ -330,7 +400,14 @@ describe('Contacts Page', () => {
});
it('should not show loading when contacts are already loaded during refresh', () => {
- useContactsStore.mockReturnValue({
+ useContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: mockContacts,
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ selectContact: jest.fn(),
+ isLoading: true, // Loading is true but contacts exist
+ fetchContacts: jest.fn(),
+ }) : {
contacts: mockContacts,
searchQuery: '',
setSearchQuery: jest.fn(),
diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx
index 9854bbc6..306791de 100644
--- a/src/app/(app)/__tests__/index.test.tsx
+++ b/src/app/(app)/__tests__/index.test.tsx
@@ -1,3 +1,15 @@
+// Mock nativewind before any imports
+jest.mock('nativewind', () => ({
+ cssInterop: jest.fn((Component: any) => Component),
+ useColorScheme: jest.fn(() => ({
+ colorScheme: 'light',
+ get: jest.fn(() => 'light'),
+ setColorScheme: jest.fn(),
+ toggleColorScheme: jest.fn(),
+ })),
+ __esModule: true,
+}));
+
import { render, waitFor } from '@testing-library/react-native';
import { useColorScheme } from 'nativewind';
import React from 'react';
@@ -65,26 +77,32 @@ jest.mock('react-i18next', () => ({
t: (key: string) => key,
}),
}));
-jest.mock('nativewind', () => ({
- useColorScheme: jest.fn(() => ({
- colorScheme: 'light',
- })),
-}));
jest.mock('@/stores/toast/store', () => ({
- useToastStore: () => ({
+ useToastStore: (selector: any) => typeof selector === 'function' ? selector({
showToast: jest.fn(),
getState: () => ({
showToast: jest.fn(),
}),
- }),
-}));
-jest.mock('@/stores/app/core-store', () => ({
- useCoreStore: {
+ }) : {
+ showToast: jest.fn(),
getState: () => ({
- setActiveCall: jest.fn(),
+ showToast: jest.fn(),
}),
},
}));
+jest.mock('@/stores/app/core-store', () => {
+ const storeState = {
+ setActiveCall: jest.fn(),
+ isInitialized: true,
+ activeCall: null,
+ activePriority: null,
+ activeUnit: null,
+ activeUnitStatus: null,
+ };
+ const mockFn = jest.fn((selector) => typeof selector === 'function' ? selector(storeState) : storeState) as jest.Mock & { getState: () => typeof storeState };
+ mockFn.getState = () => storeState;
+ return { useCoreStore: mockFn };
+});
jest.mock('@/components/maps/map-pins', () => ({
__esModule: true,
default: ({ pins, onPinPress }: any) => null,
@@ -133,7 +151,7 @@ describe('Map Component - App Lifecycle', () => {
jest.useFakeTimers();
// Setup default mocks with stable objects
- mockUseLocationStore.mockReturnValue(defaultLocationState);
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultLocationState) : defaultLocationState);
mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState);
mockUseColorScheme.mockReturnValue({
colorScheme: 'light',
@@ -208,7 +226,10 @@ describe('Map Component - App Lifecycle', () => {
it('should handle map lock state changes', async () => {
// Start with unlocked map
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLocationState,
+ isMapLocked: false,
+ }) : {
...defaultLocationState,
isMapLocked: false,
});
@@ -216,7 +237,10 @@ describe('Map Component - App Lifecycle', () => {
const { rerender, unmount } = render(, { wrapper: TestWrapper });
// Change to locked map
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLocationState,
+ isMapLocked: true,
+ }) : {
...defaultLocationState,
isMapLocked: true,
});
@@ -232,7 +256,11 @@ describe('Map Component - App Lifecycle', () => {
it('should handle navigation mode with heading', async () => {
// Mock locked map with heading
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLocationState,
+ heading: 90,
+ isMapLocked: true,
+ }) : {
...defaultLocationState,
heading: 90,
isMapLocked: true,
diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx
index 859c5713..9878c604 100644
--- a/src/app/(app)/__tests__/protocols.test.tsx
+++ b/src/app/(app)/__tests__/protocols.test.tsx
@@ -102,7 +102,7 @@ const mockProtocolsStore = {
};
jest.mock('@/stores/protocols/store', () => ({
- useProtocolsStore: () => mockProtocolsStore,
+ useProtocolsStore: (selector: any) => typeof selector === 'function' ? selector(mockProtocolsStore) : mockProtocolsStore,
}));
// Mock protocols test data
diff --git a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx
index 7d9cda92..7d4e9e78 100644
--- a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx
+++ b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx
@@ -42,7 +42,14 @@ describe('SignalR Lifecycle Management', () => {
jest.clearAllMocks();
// Mock SignalR store
- mockUseSignalRStore.mockReturnValue({
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ connectUpdateHub: mockConnectUpdateHub,
+ disconnectUpdateHub: mockDisconnectUpdateHub,
+ connectGeolocationHub: mockConnectGeolocationHub,
+ disconnectGeolocationHub: mockDisconnectGeolocationHub,
+ isUpdateHubConnected: false,
+ isGeolocationHubConnected: false,
+ } as any) : {
connectUpdateHub: mockConnectUpdateHub,
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 0db2ed58..4830126f 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -2,11 +2,10 @@
import { NovuProvider } from '@novu/react-native';
import { Redirect, SplashScreen, Tabs } from 'expo-router';
-import { size } from 'lodash';
import { Contact, ListTree, Map, Megaphone, Menu, Notebook, Settings } from 'lucide-react-native';
-import React, { useCallback, useEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { StyleSheet, useWindowDimensions } from 'react-native';
+import { ActivityIndicator, Platform, StyleSheet, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NotificationButton } from '@/components/notifications/NotificationButton';
@@ -36,10 +35,11 @@ import { useSignalRStore } from '@/stores/signalr/signalr-store';
export default function TabLayout() {
const { t } = useTranslation();
- const { status } = useAuthStore();
+ const status = useAuthStore((state) => state.status);
const [isFirstTime, _setIsFirstTime] = useIsFirstTime();
const [isOpen, setIsOpen] = React.useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = React.useState(false);
+ const [isInitComplete, setIsInitComplete] = useState(false);
const { width, height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const isLandscape = width > height;
@@ -53,6 +53,18 @@ export default function TabLayout() {
const lastSignedInStatus = useRef(null);
const parentRef = useRef(null);
+ // Render counting for diagnostics (web only)
+ const renderCount = useRef(0);
+ renderCount.current += 1;
+ if (__DEV__ && Platform.OS === 'web' && renderCount.current % 50 === 0) {
+ console.warn(`[TabLayout] render #${renderCount.current}`, {
+ status,
+ isInitComplete,
+ isOpen,
+ isLandscape,
+ });
+ }
+
const hideSplash = useCallback(async () => {
if (hasHiddenSplash.current) return;
@@ -73,14 +85,19 @@ export default function TabLayout() {
// Initialize push notifications
usePushNotifications();
- // Track when home view is rendered
+ // Track when home view is rendered (debounced - don't fire on every resize)
+ const lastTrackRef = useRef(0);
useEffect(() => {
if (status === 'signedIn') {
- trackEvent('home_view_rendered', {
- isLandscape: isLandscape,
- screenWidth: width,
- screenHeight: height,
- });
+ const now = Date.now();
+ if (now - lastTrackRef.current > 5000) {
+ lastTrackRef.current = now;
+ trackEvent('home_view_rendered', {
+ isLandscape: isLandscape,
+ screenWidth: width,
+ screenHeight: height,
+ });
+ }
}
}, [status, trackEvent, isLandscape, width, height]);
@@ -109,8 +126,7 @@ export default function TabLayout() {
});
try {
- // Initialize core app data
- await useCoreStore.getState().fetchConfig();
+ // Initialize core app data (init() calls fetchConfig() internally)
await useCoreStore.getState().init();
await useRolesStore.getState().init();
await useCallsStore.getState().init();
@@ -121,9 +137,11 @@ export default function TabLayout() {
hasInitialized.current = true;
- // Initialize Bluetooth service
- await bluetoothAudioService.initialize();
- await audioService.initialize();
+ // Initialize Bluetooth and Audio services (native-only)
+ if (Platform.OS !== 'web') {
+ await bluetoothAudioService.initialize();
+ await audioService.initialize();
+ }
logger.info({
message: 'App initialization completed successfully',
@@ -137,6 +155,7 @@ export default function TabLayout() {
hasInitialized.current = false;
} finally {
isInitializing.current = false;
+ setIsInitComplete(true);
}
}, [status]);
@@ -195,6 +214,10 @@ export default function TabLayout() {
// Handle app resuming from background - separate from initialization
useEffect(() => {
+ // On web, isActive/appState are always active — skip the initial fire
+ // and only refresh when they genuinely change (i.e., on native background→foreground)
+ if (Platform.OS === 'web') return;
+
// Only trigger on state change, not on initial render
if (isActive && appState === 'active' && hasInitialized.current) {
const timer = setTimeout(() => {
@@ -205,10 +228,10 @@ export default function TabLayout() {
}
}, [isActive, appState, refreshDataFromBackground]);
- // Force drawer open in landscape
+ // Force drawer open in landscape (guard with functional update to avoid unnecessary re-render)
useEffect(() => {
if (isLandscape) {
- setIsOpen(true);
+ setIsOpen((prev) => (prev ? prev : true));
}
}, [isLandscape]);
@@ -217,147 +240,222 @@ export default function TabLayout() {
const activeUnitId = useCoreStore((state) => state.activeUnitId);
const rights = securityStore((state) => state.rights);
+ // Compute Novu readiness once for consistent gating across the render
+ const novuReady = !!(activeUnitId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode);
+ // Cache the last known-good Novu config so NovuProvider stays mounted stably
+ // even if a transient state update briefly nullifies one of the values.
+ const lastNovuConfig = useRef<{
+ subscriberId: string;
+ applicationIdentifier: string;
+ backendUrl: string;
+ socketUrl: string;
+ } | null>(null);
+ if (novuReady) {
+ lastNovuConfig.current = {
+ subscriberId: `${rights?.DepartmentCode}_Unit_${activeUnitId}`,
+ applicationIdentifier: config!.NovuApplicationId,
+ backendUrl: config!.NovuBackendApiUrl,
+ socketUrl: config!.NovuSocketUrl,
+ };
+ }
+
+ // Memoize screen options to prevent new objects every render
+ const screenOptions = React.useMemo(
+ () => ({
+ headerShown: true,
+ tabBarShowLabel: true,
+ tabBarIconStyle: {
+ width: 24,
+ height: 24,
+ },
+ tabBarLabelStyle: {
+ fontSize: isLandscape ? 12 : 10,
+ fontWeight: '500' as const,
+ },
+ tabBarStyle: {
+ paddingBottom: Math.max(insets.bottom, 5),
+ paddingTop: 5,
+ height: isLandscape ? 65 : Math.max(60 + insets.bottom, 60),
+ elevation: 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: -1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 2,
+ zIndex: 100,
+ backgroundColor: 'transparent',
+ borderTopWidth: 0.5,
+ borderTopColor: 'rgba(0, 0, 0, 0.1)',
+ },
+ }),
+ [isLandscape, insets.bottom]
+ );
+
+ // Memoize header callbacks to prevent new function refs every render
+ const handleOpenDrawer = useCallback(() => setIsOpen(true), []);
+ const handleCloseDrawer = useCallback(() => setIsOpen(false), []);
+ const handleOpenNotifications = useCallback(() => setIsNotificationsOpen(true), []);
+ const handleCloseNotifications = useCallback(() => setIsNotificationsOpen(false), []);
+
+ // Memoize per-screen tab bar icon renderers to prevent new functions every render
+ const mapIcon = useCallback(({ color }: { color: string }) => , []);
+ const callsIcon = useCallback(({ color }: { color: string }) => , []);
+ const contactsIcon = useCallback(({ color }: { color: string }) => , []);
+ const notesIcon = useCallback(({ color }: { color: string }) => , []);
+ const protocolsIcon = useCallback(({ color }: { color: string }) => , []);
+ const settingsIcon = useCallback(({ color }: { color: string }) => , []);
+
+ // Memoize header left/right renders
+ const headerLeftMap = useCallback(() => , [isLandscape]);
+ const headerRightNotification = useCallback(
+ () => ,
+ [config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode]
+ );
+
+ // Memoize per-screen options to prevent new objects every render
+ const indexOptions = useMemo(
+ () => ({
+ title: t('tabs.map'),
+ tabBarIcon: mapIcon,
+ headerLeft: headerLeftMap,
+ tabBarButtonTestID: 'map-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, mapIcon, headerLeftMap, headerRightNotification]
+ );
+
+ const callsOptions = useMemo(
+ () => ({
+ title: t('tabs.calls'),
+ headerShown: true as const,
+ tabBarIcon: callsIcon,
+ tabBarButtonTestID: 'calls-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, callsIcon, headerRightNotification]
+ );
+
+ const contactsOptions = useMemo(
+ () => ({
+ title: t('tabs.contacts'),
+ headerShown: true as const,
+ tabBarIcon: contactsIcon,
+ tabBarButtonTestID: 'contacts-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, contactsIcon, headerRightNotification]
+ );
+
+ const notesOptions = useMemo(
+ () => ({
+ title: t('tabs.notes'),
+ headerShown: true as const,
+ tabBarIcon: notesIcon,
+ tabBarButtonTestID: 'notes-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, notesIcon, headerRightNotification]
+ );
+
+ const protocolsOptions = useMemo(
+ () => ({
+ title: t('tabs.protocols'),
+ headerShown: true as const,
+ tabBarIcon: protocolsIcon,
+ tabBarButtonTestID: 'protocols-tab' as const,
+ headerRight: headerRightNotification,
+ }),
+ [t, protocolsIcon, headerRightNotification]
+ );
+
+ const settingsOptions = useMemo(
+ () => ({
+ title: t('tabs.settings'),
+ headerShown: true as const,
+ tabBarIcon: settingsIcon,
+ tabBarButtonTestID: 'settings-tab' as const,
+ }),
+ [t, settingsIcon]
+ );
+
if (isFirstTime) {
- //setIsOnboarding();
return ;
}
if (status === 'signedOut') {
return ;
}
+ // Guard against rendering full UI before auth state is determined
+ if (status !== 'signedIn') {
+ return ;
+ }
+
const content = (
+ {/* Loading overlay during initialization — shown on top of Tabs so the navigator stays mounted */}
+ {!isInitComplete ? (
+
+
+
+ ) : null}
- {/* Drawer - conditionally rendered as permanent in landscape */}
- {isLandscape ? (
-
-
-
- ) : (
- setIsOpen(false)} {...({} as any)}>
- setIsOpen(false)} />
-
-
-
-
-
-
- )}
+ {/* Drawer and sidebar only render after init to avoid heavy re-renders during store settling */}
+ {isInitComplete ? (
+ isLandscape ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )
+ ) : null}
{/* Main content area */}
-
- ,
- headerLeft: () => ,
- tabBarButtonTestID: 'map-tab',
- headerRight: () => ,
- }}
- />
-
- ,
- tabBarButtonTestID: 'calls-tab',
- headerRight: () => ,
- }}
- />
-
- ,
- tabBarButtonTestID: 'contacts-tab',
- headerRight: () => ,
- }}
- />
-
- ,
- tabBarButtonTestID: 'notes-tab',
- headerRight: () => ,
- }}
- />
-
- ,
- tabBarButtonTestID: 'protocols-tab',
- headerRight: () => ,
- }}
- />
-
- ,
- tabBarButtonTestID: 'settings-tab',
- }}
- />
+
+
+
+
+
+
+
+
+
+
+
+
- {/* NotificationInbox positioned within the tab content area */}
- {activeUnitId && config && rights?.DepartmentCode && setIsNotificationsOpen(false)} />}
+ {/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */}
+ {isInitComplete && novuReady && }
);
- return (
- <>
- {activeUnitId && config && rights?.DepartmentCode ? (
-
- {content}
-
- ) : (
- content
- )}
- >
- );
+ // Keep NovuProvider mounted once it has been initialized to avoid full tree
+ // unmount/remount. We use the cached config so even if novuReady briefly goes
+ // false during a config re-fetch, the provider stays up with last-good props.
+ if (lastNovuConfig.current) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
}
interface CreateDrawerMenuButtonProps {
@@ -389,7 +487,7 @@ const CreateNotificationButton = ({
departmentCode,
}: {
config: GetConfigResultData | null;
- setIsNotificationsOpen: (isOpen: boolean) => void;
+ setIsNotificationsOpen: () => void;
activeUnitId: string | null;
departmentCode: string | undefined;
}) => {
@@ -397,11 +495,10 @@ const CreateNotificationButton = ({
return null;
}
- return (
-
- setIsNotificationsOpen(true)} />
-
- );
+ // No NovuProvider here — the outer NovuProvider in TabLayout already wraps everything,
+ // so NotificationButton can access Novu context directly. This avoids creating 5 duplicate
+ // socket.io connections (one per tab header).
+ return ;
};
const styles = StyleSheet.create({
@@ -410,4 +507,17 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
+ loadingContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: Platform.OS === 'web' ? '#ffffff' : undefined,
+ },
+ loadingOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: Platform.OS === 'web' ? '#ffffff' : 'rgba(255,255,255,0.95)',
+ zIndex: 1000,
+ },
});
diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx
index 005e0a1a..dc0791bc 100644
--- a/src/app/(app)/calls.tsx
+++ b/src/app/(app)/calls.tsx
@@ -16,11 +16,15 @@ import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
import { useAnalytics } from '@/hooks/use-analytics';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { useCallsStore } from '@/stores/calls/store';
-import { useSecurityStore } from '@/stores/security/store';
+import { securityStore } from '@/stores/security/store';
export default function Calls() {
- const { calls, isLoading, error, fetchCalls, fetchCallPriorities } = useCallsStore();
- const { canUserCreateCalls } = useSecurityStore();
+ const calls = useCallsStore((state) => state.calls);
+ const isLoading = useCallsStore((state) => state.isLoading);
+ const error = useCallsStore((state) => state.error);
+ const fetchCalls = useCallsStore((state) => state.fetchCalls);
+ const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities);
+ const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls);
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx
index 1657bd37..ca14dc27 100644
--- a/src/app/(app)/contacts.tsx
+++ b/src/app/(app)/contacts.tsx
@@ -17,7 +17,12 @@ import { useContactsStore } from '@/stores/contacts/store';
export default function Contacts() {
const { t } = useTranslation();
- const { contacts, searchQuery, setSearchQuery, selectContact, isLoading, fetchContacts } = useContactsStore();
+ const contacts = useContactsStore((s) => s.contacts);
+ const searchQuery = useContactsStore((s) => s.searchQuery);
+ const setSearchQuery = useContactsStore((s) => s.setSearchQuery);
+ const selectContact = useContactsStore((s) => s.selectContact);
+ const isLoading = useContactsStore((s) => s.isLoading);
+ const fetchContacts = useContactsStore((s) => s.fetchContacts);
const { trackEvent } = useAnalytics();
const [refreshing, setRefreshing] = React.useState(false);
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index 928f2203..55a6744b 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -1,12 +1,13 @@
import { Stack, useFocusEffect } from 'expo-router';
import { NavigationIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
+import { Animated, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
+import { Loading } from '@/components/common/loading';
import MapPins from '@/components/maps/map-pins';
import Mapbox from '@/components/maps/mapbox';
import PinDetailModal from '@/components/maps/pin-detail-modal';
@@ -16,7 +17,6 @@ import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
-import { onSortOptions } from '@/lib/utils';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { locationService } from '@/services/location';
import { useCoreStore } from '@/stores/app/core-store';
@@ -26,6 +26,18 @@ import { useToastStore } from '@/stores/toast/store';
Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
export default function Map() {
+ const { t } = useTranslation();
+ const isInitialized = useCoreStore((state) => state.isInitialized);
+
+ // Gate: don't mount the heavy map/location machinery until core init is done
+ if (!isInitialized) {
+ return ;
+ }
+
+ return ;
+}
+
+function MapContent() {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const { colorScheme } = useColorScheme();
@@ -38,21 +50,10 @@ export default function Map() {
const [selectedPin, setSelectedPin] = useState(null);
const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false);
const { isActive } = useAppLifecycle();
- const location = useLocationStore((state) => ({
- latitude: state.latitude,
- longitude: state.longitude,
- heading: state.heading,
- isMapLocked: state.isMapLocked,
- }));
-
- const _mapOptions = Object.keys(Mapbox.StyleURL)
- .map((key) => {
- return {
- label: key,
- data: (Mapbox.StyleURL as any)[key],
- };
- })
- .sort(onSortOptions);
+ const locationLatitude = useLocationStore((state) => state.latitude);
+ const locationLongitude = useLocationStore((state) => state.longitude);
+ const locationHeading = useLocationStore((state) => state.heading);
+ const isMapLocked = useLocationStore((state) => state.isMapLocked);
// Get map style based on current theme
const getMapStyle = useCallback(() => {
@@ -77,18 +78,18 @@ export default function Map() {
setHasUserMovedMap(false);
// Reset camera to current location when navigating back to map
- if (isMapReady && location.latitude && location.longitude) {
+ if (isMapReady && locationLatitude && locationLongitude) {
const cameraConfig: any = {
- centerCoordinate: [location.longitude, location.latitude],
- zoomLevel: location.isMapLocked ? 16 : 12,
+ centerCoordinate: [locationLongitude, locationLatitude],
+ zoomLevel: isMapLocked ? 16 : 12,
animationDuration: 1000,
heading: 0,
pitch: 0,
};
// Add heading and pitch for navigation mode when locked
- if (location.isMapLocked && location.heading !== null && location.heading !== undefined) {
- cameraConfig.heading = location.heading;
+ if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
+ cameraConfig.heading = locationHeading;
cameraConfig.pitch = 45;
}
@@ -97,13 +98,13 @@ export default function Map() {
logger.info({
message: 'Map focused, resetting camera to current location',
context: {
- latitude: location.latitude,
- longitude: location.longitude,
- isMapLocked: location.isMapLocked,
+ latitude: locationLatitude,
+ longitude: locationLongitude,
+ isMapLocked: isMapLocked,
},
});
}
- }, [isMapReady, location.latitude, location.longitude, location.isMapLocked, location.heading])
+ }, [isMapReady, locationLatitude, locationLongitude, isMapLocked, locationHeading])
);
useEffect(() => {
@@ -133,63 +134,51 @@ export default function Map() {
}, []);
useEffect(() => {
- if (isMapReady && location.latitude && location.longitude) {
- logger.info({
- message: 'Location updated and map is ready',
- context: {
- latitude: location.latitude,
- longitude: location.longitude,
- heading: location.heading,
- isMapLocked: location.isMapLocked,
- },
- });
-
+ if (isMapReady && locationLatitude && locationLongitude) {
// When map is locked, always follow the location
// When map is unlocked, only follow if user hasn't moved the map
- if (location.isMapLocked || !hasUserMovedMap) {
+ if (isMapLocked || !hasUserMovedMap) {
const cameraConfig: any = {
- centerCoordinate: [location.longitude, location.latitude],
- zoomLevel: location.isMapLocked ? 16 : 12,
- animationDuration: location.isMapLocked ? 500 : 1000,
+ centerCoordinate: [locationLongitude, locationLatitude],
+ zoomLevel: isMapLocked ? 16 : 12,
+ animationDuration: isMapLocked ? 500 : 1000,
};
// Add heading and pitch for navigation mode when locked
- if (location.isMapLocked && location.heading !== null && location.heading !== undefined) {
- cameraConfig.heading = location.heading;
+ if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
+ cameraConfig.heading = locationHeading;
cameraConfig.pitch = 45;
}
cameraRef.current?.setCamera(cameraConfig);
}
}
- }, [isMapReady, location.latitude, location.longitude, location.heading, location.isMapLocked, hasUserMovedMap]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]);
+ // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop
+ // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger.
// Reset hasUserMovedMap when map gets locked and reset camera when unlocked
useEffect(() => {
- if (location.isMapLocked) {
+ if (isMapLocked) {
setHasUserMovedMap(false);
} else {
// When exiting locked mode, reset camera to normal view and reset user interaction state
setHasUserMovedMap(false);
- if (isMapReady && location.latitude && location.longitude) {
+ if (isMapReady && locationLatitude && locationLongitude) {
cameraRef.current?.setCamera({
- centerCoordinate: [location.longitude, location.latitude],
+ centerCoordinate: [locationLongitude, locationLatitude],
zoomLevel: 12,
heading: 0,
pitch: 0,
animationDuration: 1000,
});
- logger.info({
- message: 'Map unlocked, resetting camera to normal view and user interaction state',
- context: {
- latitude: location.latitude,
- longitude: location.longitude,
- },
- });
}
}
- }, [isMapReady, location.isMapLocked, location.latitude, location.longitude]);
+ // Only react to lock mode changes — NOT location changes (those are handled above)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isMapReady, isMapLocked]);
useEffect(() => {
const abortController = new AbortController();
@@ -225,21 +214,30 @@ export default function Map() {
};
}, []);
+ // Only run Animated.loop on native — on web, useNativeDriver falls back to JS driver
+ // which creates continuous requestAnimationFrame overhead. Web uses CSS animation instead.
useEffect(() => {
- Animated.loop(
- Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1.2,
- duration: 1000,
- useNativeDriver: true,
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: 1000,
- useNativeDriver: true,
- }),
- ])
- ).start();
+ if (Platform.OS !== 'web') {
+ const loopAnim = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, {
+ toValue: 1.2,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 1,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ loopAnim.start();
+
+ return () => {
+ loopAnim.stop();
+ };
+ }
}, [pulseAnim]);
// Track when map view is rendered
@@ -247,29 +245,32 @@ export default function Map() {
trackEvent('map_view_rendered', {
hasMapPins: mapPins.length > 0,
mapPinsCount: mapPins.length,
- isMapLocked: location.isMapLocked,
+ isMapLocked: isMapLocked,
theme: colorScheme || 'light',
});
- }, [trackEvent, mapPins.length, location.isMapLocked, colorScheme]);
+ }, [trackEvent, mapPins.length, isMapLocked, colorScheme]);
- const onCameraChanged = (event: any) => {
- // Only register user interaction if map is not locked
- if (event.properties.isUserInteraction && !location.isMapLocked) {
- setHasUserMovedMap(true);
- }
- };
+ const onCameraChanged = useCallback(
+ (event: any) => {
+ // Only register user interaction if map is not locked
+ if (event.properties.isUserInteraction && !isMapLocked) {
+ setHasUserMovedMap(true);
+ }
+ },
+ [isMapLocked]
+ );
const handleRecenterMap = () => {
- if (location.latitude && location.longitude) {
+ if (locationLatitude && locationLongitude) {
const cameraConfig: any = {
- centerCoordinate: [location.longitude, location.latitude],
- zoomLevel: location.isMapLocked ? 16 : 12,
+ centerCoordinate: [locationLongitude, locationLatitude],
+ zoomLevel: isMapLocked ? 16 : 12,
animationDuration: 1000,
};
// Add heading and pitch for navigation mode when locked
- if (location.isMapLocked && location.heading !== null && location.heading !== undefined) {
- cameraConfig.heading = location.heading;
+ if (isMapLocked && locationHeading !== null && locationHeading !== undefined) {
+ cameraConfig.heading = locationHeading;
cameraConfig.pitch = 45;
}
@@ -278,10 +279,10 @@ export default function Map() {
}
};
- const handlePinPress = (pin: MapMakerInfoData) => {
+ const handlePinPress = useCallback((pin: MapMakerInfoData) => {
setSelectedPin(pin);
setIsPinDetailModalOpen(true);
- };
+ }, []);
const handleSetAsCurrentCall = async (pin: MapMakerInfoData) => {
try {
@@ -315,10 +316,10 @@ export default function Map() {
};
// Show recenter button only when map is not locked and user has moved the map
- const showRecenterButton = !location.isMapLocked && hasUserMovedMap && location.latitude && location.longitude;
+ const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude && locationLongitude;
- // Create dynamic styles based on theme
- const getThemedStyles = useCallback(() => {
+ // Create dynamic styles based on theme - useMemo to avoid new objects every render
+ const themedStyles = useMemo(() => {
const isDark = colorScheme === 'dark';
return {
markerInnerContainer: {
@@ -361,8 +362,6 @@ export default function Map() {
};
}, [colorScheme, insets.bottom]);
- const themedStyles = getThemedStyles();
-
return (
<>
setIsMapReady(true)}
testID="map-view"
- scrollEnabled={!location.isMapLocked}
- zoomEnabled={!location.isMapLocked}
- rotateEnabled={!location.isMapLocked}
- pitchEnabled={!location.isMapLocked}
+ scrollEnabled={!isMapLocked}
+ zoomEnabled={!isMapLocked}
+ rotateEnabled={!isMapLocked}
+ pitchEnabled={!isMapLocked}
>
- {location.latitude && location.longitude && (
-
-
-
+ {locationLatitude != null && locationLongitude != null ? (
+
+
+
- {location.heading !== null && location.heading !== undefined && (
+ {locationHeading != null ? (
- )}
+ ) : null}
- )}
+ ) : null}
{/* Recenter Button - only show when map is not locked and user has moved the map */}
- {showRecenterButton && (
+ {showRecenterButton ? (
- )}
+ ) : null}
{/* Pin Detail Modal */}
@@ -504,4 +496,18 @@ const styles = StyleSheet.create({
alignItems: 'center',
// elevation and shadow properties are handled by themedStyles
},
+ // Web-only CSS pulse animation (replaces Animated.loop which falls back to JS driver on web)
+ markerPulseWeb: {
+ // No JS-driven transform on web — the outer ring animates via CSS instead
+ } as any,
+ markerOuterRingPulseWeb:
+ Platform.OS === 'web'
+ ? {
+ // @ts-ignore — web-only CSS animation properties
+ animationName: 'pulse-ring',
+ animationDuration: '2s',
+ animationIterationCount: 'infinite',
+ animationTimingFunction: 'ease-in-out',
+ }
+ : ({} as any),
});
diff --git a/src/app/(app)/notes.tsx b/src/app/(app)/notes.tsx
index 0b45b045..f51a2d4f 100644
--- a/src/app/(app)/notes.tsx
+++ b/src/app/(app)/notes.tsx
@@ -17,7 +17,12 @@ import { useNotesStore } from '@/stores/notes/store';
export default function Notes() {
const { t } = useTranslation();
- const { notes, searchQuery, setSearchQuery, selectNote, isLoading, fetchNotes } = useNotesStore();
+ const notes = useNotesStore((s) => s.notes);
+ const searchQuery = useNotesStore((s) => s.searchQuery);
+ const setSearchQuery = useNotesStore((s) => s.setSearchQuery);
+ const selectNote = useNotesStore((s) => s.selectNote);
+ const isLoading = useNotesStore((s) => s.isLoading);
+ const fetchNotes = useNotesStore((s) => s.fetchNotes);
const { trackEvent } = useAnalytics();
const [refreshing, setRefreshing] = React.useState(false);
diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx
index 35919dd3..00429519 100644
--- a/src/app/(app)/protocols.tsx
+++ b/src/app/(app)/protocols.tsx
@@ -18,7 +18,12 @@ import { useProtocolsStore } from '@/stores/protocols/store';
export default function Protocols() {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
- const { protocols, searchQuery, setSearchQuery, selectProtocol, isLoading, fetchProtocols } = useProtocolsStore();
+ const protocols = useProtocolsStore((s) => s.protocols);
+ const searchQuery = useProtocolsStore((s) => s.searchQuery);
+ const setSearchQuery = useProtocolsStore((s) => s.setSearchQuery);
+ const selectProtocol = useProtocolsStore((s) => s.selectProtocol);
+ const isLoading = useProtocolsStore((s) => s.isLoading);
+ const fetchProtocols = useProtocolsStore((s) => s.fetchProtocols);
const [refreshing, setRefreshing] = React.useState(false);
React.useEffect(() => {
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index b52ea0f3..bbafc21f 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -42,7 +42,7 @@ export default function Settings() {
const [showUnitSelection, setShowUnitSelection] = React.useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false);
const activeUnit = useCoreStore((state) => state.activeUnit);
- const { units } = useUnitsStore();
+ const units = useUnitsStore((state) => state.units);
const activeUnitName = React.useMemo(() => {
if (!activeUnit) return t('settings.none_selected');
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index e3db628a..199b82df 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -47,14 +47,14 @@ const navigationIntegration = Sentry.reactNavigationIntegration({
Sentry.init({
dsn: Env.SENTRY_DSN,
debug: __DEV__, // Only debug in development, not production
- tracesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact
- profilesSampleRate: __DEV__ ? 1.0 : 0.2, // 100% in dev, 20% in production to reduce performance impact
+ tracesSampleRate: __DEV__ ? 0.1 : 0.2, // 10% in dev (low to avoid setTimeout wrapping overhead), 20% in production
+ profilesSampleRate: __DEV__ ? 0.1 : 0.2, // 10% in dev, 20% in production
sendDefaultPii: false,
integrations: [
// Pass integration
navigationIntegration,
],
- enableNativeFramesTracking: true, //!isRunningInExpoGo(), // Tracks slow and frozen frames in the application
+ enableNativeFramesTracking: Platform.OS !== 'web', //!isRunningInExpoGo(), // Tracks slow and frozen frames in the application
// Add additional options to prevent timing issues
beforeSendTransaction(event: any) {
// Filter out problematic navigation transactions that might cause timestamp errors
@@ -105,20 +105,22 @@ function RootLayout() {
navigationIntegration.registerNavigationContainer(ref);
}
- // Clear the badge count on app startup
- notifee
- .setBadgeCount(0)
- .then(() => {
- logger.info({
- message: 'Badge count cleared on startup',
- });
- })
- .catch((error: Error) => {
- logger.error({
- message: 'Failed to clear badge count on startup',
- context: { error },
+ // Clear the badge count on app startup (native only — notifee has no web implementation)
+ if (Platform.OS !== 'web') {
+ notifee
+ .setBadgeCount(0)
+ .then(() => {
+ logger.info({
+ message: 'Badge count cleared on startup',
+ });
+ })
+ .catch((error: Error) => {
+ logger.error({
+ message: 'Failed to clear badge count on startup',
+ context: { error },
+ });
});
- });
+ }
// Load keep alive state on app startup
loadKeepAliveState()
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 5e7a68b9..2cea6d80 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -25,7 +25,7 @@ import { openMapsWithDirections } from '@/lib/navigation';
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useCallDetailStore } from '@/stores/calls/detail-store';
-import { useSecurityStore } from '@/stores/security/store';
+import { securityStore } from '@/stores/security/store';
import { useStatusBottomSheetStore } from '@/stores/status/store';
import { useToastStore } from '@/stores/toast/store';
@@ -51,10 +51,19 @@ export default function CallDetail() {
latitude: null,
longitude: null,
});
- const { call, callExtraData, callPriority, isLoading, error, fetchCallDetail, reset } = useCallDetailStore();
- const { canUserCreateCalls } = useSecurityStore();
- const { activeCall, activeStatuses, activeUnit } = useCoreStore();
- const { setIsOpen: setStatusBottomSheetOpen, setSelectedCall } = useStatusBottomSheetStore();
+ const call = useCallDetailStore((state) => state.call);
+ const callExtraData = useCallDetailStore((state) => state.callExtraData);
+ const callPriority = useCallDetailStore((state) => state.callPriority);
+ const isLoading = useCallDetailStore((state) => state.isLoading);
+ const error = useCallDetailStore((state) => state.error);
+ const fetchCallDetail = useCallDetailStore((state) => state.fetchCallDetail);
+ const reset = useCallDetailStore((state) => state.reset);
+ const canUserCreateCalls = securityStore((state) => state.rights?.CanCreateCalls);
+ const activeCall = useCoreStore((state) => state.activeCall);
+ const activeStatuses = useCoreStore((state) => state.activeStatuses);
+ const activeUnit = useCoreStore((state) => state.activeUnit);
+ const setStatusBottomSheetOpen = useStatusBottomSheetStore((state) => state.setIsOpen);
+ const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall);
const [isNotesModalOpen, setIsNotesModalOpen] = useState(false);
const [isImagesModalOpen, setIsImagesModalOpen] = useState(false);
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
@@ -66,10 +75,8 @@ export default function CallDetail() {
const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000';
// Get current user location from the location store
- const userLocation = useLocationStore((state) => ({
- latitude: state.latitude,
- longitude: state.longitude,
- }));
+ const userLatitude = useLocationStore((state) => state.latitude);
+ const userLongitude = useLocationStore((state) => state.longitude);
const handleBack = () => {
router.back();
@@ -186,7 +193,7 @@ export default function CallDetail() {
try {
const destinationName = call?.Address || t('call_detail.call_location');
- const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLocation.latitude || undefined, userLocation.longitude || undefined);
+ const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLatitude || undefined, userLongitude || undefined);
if (!success) {
showToast('error', t('call_detail.failed_to_open_maps'));
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index 2e165f2c..d830c47b 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -77,9 +77,17 @@ export default function EditCall() {
const { colorScheme } = useColorScheme();
const { id } = useLocalSearchParams();
const callId = Array.isArray(id) ? id[0] : id;
- const { callPriorities, callTypes, isLoading: callDataLoading, error: callDataError, fetchCallPriorities, fetchCallTypes } = useCallsStore();
- const { call, isLoading: callDetailLoading, error: callDetailError, fetchCallDetail } = useCallDetailStore();
- const { config } = useCoreStore();
+ const callPriorities = useCallsStore((state) => state.callPriorities);
+ const callTypes = useCallsStore((state) => state.callTypes);
+ const callDataLoading = useCallsStore((state) => state.isLoading);
+ const callDataError = useCallsStore((state) => state.error);
+ const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities);
+ const fetchCallTypes = useCallsStore((state) => state.fetchCallTypes);
+ const call = useCallDetailStore((state) => state.call);
+ const callDetailLoading = useCallDetailStore((state) => state.isLoading);
+ const callDetailError = useCallDetailStore((state) => state.error);
+ const fetchCallDetail = useCallDetailStore((state) => state.fetchCallDetail);
+ const config = useCoreStore((state) => state.config);
const toast = useToast();
const [showLocationPicker, setShowLocationPicker] = useState(false);
const [showDispatchModal, setShowDispatchModal] = useState(false);
diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx
index fef3552a..522dc596 100644
--- a/src/app/call/__tests__/[id].security.test.tsx
+++ b/src/app/call/__tests__/[id].security.test.tsx
@@ -120,6 +120,7 @@ jest.mock('@/stores/calls/detail-store', () => ({
}));
jest.mock('@/stores/security/store', () => ({
+ securityStore: jest.fn(),
useSecurityStore: jest.fn(),
}));
@@ -308,7 +309,7 @@ import CallDetail from '../[id]';
describe('CallDetail', () => {
const { useCallDetailStore } = require('@/stores/calls/detail-store');
- const { useSecurityStore } = require('@/stores/security/store');
+ const { securityStore, useSecurityStore } = require('@/stores/security/store');
const { useCoreStore } = require('@/stores/app/core-store');
const { useLocationStore } = require('@/stores/app/location-store');
const { useStatusBottomSheetStore } = require('@/stores/status/store');
@@ -316,17 +317,64 @@ describe('CallDetail', () => {
beforeEach(() => {
jest.clearAllMocks();
- useCallDetailStore.mockReturnValue(mockCallDetailStore);
- useSecurityStore.mockReturnValue(mockSecurityStore);
- useCoreStore.mockReturnValue(mockCoreStore);
- useLocationStore.mockReturnValue(mockLocationStore);
- useStatusBottomSheetStore.mockReturnValue(mockStatusBottomSheetStore);
- useToastStore.mockReturnValue(mockToastStore);
+
+ // Setup stores as selector-based stores
+ useCallDetailStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockCallDetailStore);
+ }
+ return mockCallDetailStore;
+ });
+
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore);
+
+ useCoreStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockCoreStore);
+ }
+ return mockCoreStore;
+ });
+
+ useLocationStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockLocationStore);
+ }
+ return mockLocationStore;
+ });
+
+ useStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockStatusBottomSheetStore);
+ }
+ return mockStatusBottomSheetStore;
+ });
+
+ useToastStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockToastStore);
+ }
+ return mockToastStore;
+ });
+
+ // Setup securityStore as a selector-based store
+ securityStore.mockImplementation((selector: any) => {
+ const state = {
+ rights: {
+ CanCreateCalls: mockSecurityStore.canUserCreateCalls,
+ },
+ };
+ if (selector) {
+ return selector(state);
+ }
+ return state;
+ });
});
describe('Security-dependent rendering', () => {
it('should render successfully when user has create calls permission', () => {
- useSecurityStore.mockReturnValue({
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ canUserCreateCalls: true,
+ }) : {
canUserCreateCalls: true,
});
@@ -334,7 +382,9 @@ describe('CallDetail', () => {
});
it('should render successfully when user does not have create calls permission', () => {
- useSecurityStore.mockReturnValue({
+ useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ canUserCreateCalls: false,
+ }) : {
canUserCreateCalls: false,
});
@@ -355,7 +405,11 @@ describe('CallDetail', () => {
describe('Loading and error states', () => {
it('should handle loading state', () => {
- useCallDetailStore.mockReturnValue({
+ useCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ isLoading: true,
+ call: null,
+ }) : {
...mockCallDetailStore,
isLoading: true,
call: null,
@@ -365,7 +419,12 @@ describe('CallDetail', () => {
});
it('should handle error state', () => {
- useCallDetailStore.mockReturnValue({
+ useCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ isLoading: false,
+ error: 'Network error',
+ call: null,
+ }) : {
...mockCallDetailStore,
isLoading: false,
error: 'Network error',
diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx
index 3f70d4ea..8e311d77 100644
--- a/src/app/call/__tests__/[id].test.tsx
+++ b/src/app/call/__tests__/[id].test.tsx
@@ -10,6 +10,7 @@ import { useCallDetailStore } from '@/stores/calls/detail-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useStatusBottomSheetStore } from '@/stores/status/store';
import { useToastStore } from '@/stores/toast/store';
+import { securityStore } from '@/stores/security/store';
import CallDetail from '../[id]';
@@ -226,6 +227,10 @@ jest.mock('@/stores/toast/store', () => ({
useToastStore: jest.fn(),
}));
+jest.mock('@/stores/security/store', () => ({
+ securityStore: jest.fn(),
+}));
+
jest.mock('../../../components/calls/call-detail-menu', () => ({
useCallDetailMenu: jest.fn(() => ({
HeaderRightMenu: () => null,
@@ -405,8 +410,49 @@ const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction;
const mockUseStatusBottomSheetStore = useStatusBottomSheetStore as jest.MockedFunction;
const mockUseToastStore = useToastStore as jest.MockedFunction;
+const mockSecurityStore = securityStore as jest.MockedFunction;
describe('CallDetail', () => {
+ const defaultCallDetailStore = {
+ call: null,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: true,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+
+ const defaultCoreStore = {
+ activeCall: null,
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' },
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+
+ const defaultLocationStore = {
+ latitude: 40.7128,
+ longitude: -74.0060,
+ };
+
+ const defaultStatusBottomSheetStore = {
+ setIsOpen: jest.fn(),
+ setSelectedCall: jest.fn(),
+ };
+
+ const defaultSecurityStore = {
+ rights: { CanCreateCalls: true },
+ getRights: jest.fn(),
+ error: null,
+ };
+
beforeEach(() => {
jest.clearAllMocks();
@@ -418,40 +464,47 @@ describe('CallDetail', () => {
id: 'test-call-id',
});
- mockUseLocationStore.mockReturnValue({
- latitude: 40.7128,
- longitude: -74.0060,
+ mockUseLocationStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultLocationStore);
+ }
+ return defaultLocationStore;
});
- mockUseToastStore.mockReturnValue(jest.fn());
-
- mockUseCoreStore.mockReturnValue({
- activeCall: null,
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Mock active unit
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: jest.fn() };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: jest.fn(),
- setSelectedCall: jest.fn(),
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultCoreStore);
+ }
+ return defaultCoreStore;
});
- mockUseCallDetailStore.mockReturnValue({
- call: null,
- callExtraData: null,
- callPriority: null,
- isLoading: true,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultStatusBottomSheetStore);
+ }
+ return defaultStatusBottomSheetStore;
+ });
+
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultCallDetailStore);
+ }
+ return defaultCallDetailStore;
+ });
+
+ mockSecurityStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultSecurityStore);
+ }
+ return defaultSecurityStore;
});
});
@@ -476,7 +529,7 @@ describe('CallDetail', () => {
Activity: [{ StatusText: 'Dispatched' }],
};
- mockUseCallDetailStore.mockReturnValue({
+ const callDetailStore = {
call: mockCall,
callExtraData: mockCallExtraData,
callPriority: { Name: 'High', Color: '#ff0000' },
@@ -484,6 +537,13 @@ describe('CallDetail', () => {
error: null,
fetchCallDetail: jest.fn(),
reset: jest.fn(),
+ };
+
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(callDetailStore);
+ }
+ return callDetailStore;
});
render();
@@ -509,7 +569,7 @@ describe('CallDetail', () => {
});
it('should not track analytics when call data is not available', () => {
- mockUseCallDetailStore.mockReturnValue({
+ const callDetailStore = {
call: null,
callExtraData: null,
callPriority: null,
@@ -517,6 +577,13 @@ describe('CallDetail', () => {
error: null,
fetchCallDetail: jest.fn(),
reset: jest.fn(),
+ };
+
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(callDetailStore);
+ }
+ return callDetailStore;
});
render();
@@ -539,7 +606,7 @@ describe('CallDetail', () => {
FileCount: 0,
};
- mockUseCallDetailStore.mockReturnValue({
+ const callDetailStore = {
call: mockCall,
callExtraData: null,
callPriority: null,
@@ -547,6 +614,13 @@ describe('CallDetail', () => {
error: null,
fetchCallDetail: jest.fn(),
reset: jest.fn(),
+ };
+
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(callDetailStore);
+ }
+ return callDetailStore;
});
render();
@@ -590,32 +664,50 @@ describe('CallDetail', () => {
const mockSetIsOpen = jest.fn();
const mockSetSelectedCall = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' }, // Different call is active
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' }, // Different call is active
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
const { getByText, getAllByText, debug, getByTestId } = render();
@@ -629,27 +721,39 @@ describe('CallDetail', () => {
});
it('should not show "Set Active" button when call is already the active call', () => {
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'test-call-id' }, // Same call is active
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'test-call-id' }, // Same call is active
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
const { queryByText } = render();
@@ -659,27 +763,39 @@ describe('CallDetail', () => {
});
it('should not show "Set Active" button when there is no active unit', () => {
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' }, // Different call is active
- activeUnit: null, // No active unit
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' }, // Different call is active
+ activeUnit: null, // No active unit
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
const { queryByText, queryByTestId } = render();
@@ -695,27 +811,39 @@ describe('CallDetail', () => {
const mockSetActiveCall = jest.fn().mockResolvedValue(undefined);
const mockShowToast = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' }, // Different call is active
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' }, // Different call is active
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
// Mock the core store getState method
@@ -723,12 +851,24 @@ describe('CallDetail', () => {
setActiveCall: mockSetActiveCall,
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
const { getByTestId } = render();
@@ -763,39 +903,63 @@ describe('CallDetail', () => {
});
const mockShowToast = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' },
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' },
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
useCoreStore.getState = jest.fn().mockReturnValue({
setActiveCall: mockSetActiveCall,
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
const { getByTestId } = render();
@@ -828,39 +992,63 @@ describe('CallDetail', () => {
const mockSetActiveCall = jest.fn().mockRejectedValue(new Error('Network error'));
const mockShowToast = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' },
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' },
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
useCoreStore.getState = jest.fn().mockReturnValue({
setActiveCall: mockSetActiveCall,
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
const { getByTestId } = render();
@@ -886,27 +1074,39 @@ describe('CallDetail', () => {
const mockSetActiveCall = jest.fn().mockRejectedValue(new Error('Network error'));
const mockShowToast = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' }, // Different call is active
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' }, // Different call is active
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 2, Type: 1, StateId: 2, Text: 'En Route', BColor: '#f8ac59', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
// Mock the core store getState method to return a failing setActiveCall
@@ -914,12 +1114,24 @@ describe('CallDetail', () => {
setActiveCall: mockSetActiveCall,
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
const { getByTestId } = render();
@@ -947,27 +1159,39 @@ describe('CallDetail', () => {
const mockSetActiveCall = jest.fn().mockResolvedValue(undefined);
const mockShowToast = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
- call: mockCall,
- callExtraData: null,
- callPriority: null,
- isLoading: false,
- error: null,
- fetchCallDetail: jest.fn(),
- reset: jest.fn(),
- });
-
- mockUseCoreStore.mockReturnValue({
- activeCall: { CallId: 'different-call-id' }, // Different call is active
- activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
- activeStatuses: {
- UnitType: '1',
- StatusId: '1',
- Statuses: [
- { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
- ],
- },
+ mockUseCallDetailStore.mockImplementation((selector: any) => {
+ const store = {
+ call: mockCall,
+ callExtraData: null,
+ callPriority: null,
+ isLoading: false,
+ error: null,
+ fetchCallDetail: jest.fn(),
+ reset: jest.fn(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
+
+ mockUseCoreStore.mockImplementation((selector: any) => {
+ const store = {
+ activeCall: { CallId: 'different-call-id' }, // Different call is active
+ activeUnit: { UnitId: 'test-unit-id', Name: 'Unit 1' }, // Active unit exists
+ activeStatuses: {
+ UnitType: '1',
+ StatusId: '1',
+ Statuses: [
+ { Id: 1, Type: 1, StateId: 1, Text: 'Available', BColor: '#449d44', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ { Id: 3, Type: 1, StateId: 3, Text: 'On Scene', BColor: '#ed5565', Color: '#fff', Gps: false, Note: 0, Detail: 0 },
+ ],
+ },
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
// Mock the core store getState method
@@ -975,12 +1199,24 @@ describe('CallDetail', () => {
setActiveCall: mockSetActiveCall,
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- setIsOpen: mockSetIsOpen,
- setSelectedCall: mockSetSelectedCall,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ setIsOpen: mockSetIsOpen,
+ setSelectedCall: mockSetSelectedCall,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
const { getByTestId } = render();
diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx
index ad623221..b1b374a8 100644
--- a/src/app/call/new/index.tsx
+++ b/src/app/call/new/index.tsx
@@ -105,8 +105,13 @@ export default function NewCall() {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
- const { callPriorities, callTypes, isLoading, error, fetchCallPriorities, fetchCallTypes } = useCallsStore();
- const { config } = useCoreStore();
+ const callPriorities = useCallsStore((state) => state.callPriorities);
+ const callTypes = useCallsStore((state) => state.callTypes);
+ const isLoading = useCallsStore((state) => state.isLoading);
+ const error = useCallsStore((state) => state.error);
+ const fetchCallPriorities = useCallsStore((state) => state.fetchCallPriorities);
+ const fetchCallTypes = useCallsStore((state) => state.fetchCallTypes);
+ const config = useCoreStore((state) => state.config);
const { trackEvent } = useAnalytics();
const toast = useToast();
const [showLocationPicker, setShowLocationPicker] = useState(false);
diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx
index 7a96679f..5d468558 100644
--- a/src/app/onboarding.tsx
+++ b/src/app/onboarding.tsx
@@ -63,7 +63,7 @@ const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ curren
export default function Onboarding() {
const [_, setIsFirstTime] = useIsFirstTime();
- const { status, setIsOnboarding } = useAuthStore();
+ const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding);
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef>(null);
diff --git a/src/components/audio-stream/audio-stream-bottom-sheet.tsx b/src/components/audio-stream/audio-stream-bottom-sheet.tsx
index 2b3934e6..f3103d47 100644
--- a/src/components/audio-stream/audio-stream-bottom-sheet.tsx
+++ b/src/components/audio-stream/audio-stream-bottom-sheet.tsx
@@ -16,7 +16,17 @@ export const AudioStreamBottomSheet = () => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
- const { isBottomSheetVisible, setIsBottomSheetVisible, availableStreams, currentStream, isLoadingStreams, isPlaying, isLoading, isBuffering, fetchAvailableStreams, playStream, stopStream } = useAudioStreamStore();
+ const isBottomSheetVisible = useAudioStreamStore((s) => s.isBottomSheetVisible);
+ const setIsBottomSheetVisible = useAudioStreamStore((s) => s.setIsBottomSheetVisible);
+ const availableStreams = useAudioStreamStore((s) => s.availableStreams);
+ const currentStream = useAudioStreamStore((s) => s.currentStream);
+ const isLoadingStreams = useAudioStreamStore((s) => s.isLoadingStreams);
+ const isPlaying = useAudioStreamStore((s) => s.isPlaying);
+ const isLoading = useAudioStreamStore((s) => s.isLoading);
+ const isBuffering = useAudioStreamStore((s) => s.isBuffering);
+ const fetchAvailableStreams = useAudioStreamStore((s) => s.fetchAvailableStreams);
+ const playStream = useAudioStreamStore((s) => s.playStream);
+ const stopStream = useAudioStreamStore((s) => s.stopStream);
useEffect(() => {
// Fetch available streams when bottom sheet opens
diff --git a/src/components/bluetooth/bluetooth-audio-modal.tsx b/src/components/bluetooth/bluetooth-audio-modal.tsx
index 229b7b60..7fc73aed 100644
--- a/src/components/bluetooth/bluetooth-audio-modal.tsx
+++ b/src/components/bluetooth/bluetooth-audio-modal.tsx
@@ -22,9 +22,18 @@ interface BluetoothAudioModalProps {
}
const BluetoothAudioModal: React.FC = ({ isOpen, onClose }) => {
- const { bluetoothState, isScanning, isConnecting, availableDevices, connectedDevice, connectionError, isAudioRoutingActive, buttonEvents, lastButtonAction } = useBluetoothAudioStore();
-
- const { isConnected: isLiveKitConnected, currentRoom } = useLiveKitStore();
+ const bluetoothState = useBluetoothAudioStore((s) => s.bluetoothState);
+ const isScanning = useBluetoothAudioStore((s) => s.isScanning);
+ const isConnecting = useBluetoothAudioStore((s) => s.isConnecting);
+ const availableDevices = useBluetoothAudioStore((s) => s.availableDevices);
+ const connectedDevice = useBluetoothAudioStore((s) => s.connectedDevice);
+ const connectionError = useBluetoothAudioStore((s) => s.connectionError);
+ const isAudioRoutingActive = useBluetoothAudioStore((s) => s.isAudioRoutingActive);
+ const buttonEvents = useBluetoothAudioStore((s) => s.buttonEvents);
+ const lastButtonAction = useBluetoothAudioStore((s) => s.lastButtonAction);
+
+ const isLiveKitConnected = useLiveKitStore((s) => s.isConnected);
+ const currentRoom = useLiveKitStore((s) => s.currentRoom);
const [isMicMuted, setIsMicMuted] = useState(false);
const handleStartScan = React.useCallback(async () => {
diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx
index 0e729df7..56dba836 100644
--- a/src/components/calls/__tests__/call-files-modal.test.tsx
+++ b/src/components/calls/__tests__/call-files-modal.test.tsx
@@ -61,7 +61,12 @@ const resetMockStoreState = () => {
};
jest.mock('@/stores/calls/detail-store', () => ({
- useCallDetailStore: () => mockStoreState,
+ useCallDetailStore: (selector: any) => {
+ if (selector) {
+ return selector(mockStoreState);
+ }
+ return mockStoreState;
+ },
}));
// Mock analytics
diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx
index 32430571..3a6a214a 100644
--- a/src/components/calls/__tests__/call-images-modal.test.tsx
+++ b/src/components/calls/__tests__/call-images-modal.test.tsx
@@ -345,8 +345,21 @@ describe('CallImagesModal', () => {
jest.clearAllMocks();
mockReadAsStringAsync.mockClear();
mockManipulateAsync.mockClear();
- mockUseCallDetailStore.mockReturnValue(mockStore as any);
- mockUseLocationStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore as any) : mockStore as any);
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ latitude: 40.7128,
+ longitude: -74.0060,
+ heading: null,
+ accuracy: null,
+ speed: null,
+ altitude: null,
+ timestamp: null,
+ isBackgroundEnabled: false,
+ isMapLocked: false,
+ setLocation: jest.fn(),
+ setBackgroundEnabled: jest.fn(),
+ setMapLocked: jest.fn(),
+ }) : {
latitude: 40.7128,
longitude: -74.0060,
heading: null,
@@ -398,7 +411,10 @@ describe('CallImagesModal', () => {
});
it('shows loading state', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isLoadingImages: true,
+ } as any) : {
...mockStore,
isLoadingImages: true,
} as any);
@@ -408,7 +424,10 @@ describe('CallImagesModal', () => {
});
it('shows error state', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ errorImages: 'Failed to load images',
+ } as any) : {
...mockStore,
errorImages: 'Failed to load images',
} as any);
@@ -418,7 +437,10 @@ describe('CallImagesModal', () => {
});
it('shows zero state when no images', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ callImages: [],
+ } as any) : {
...mockStore,
callImages: [],
} as any);
@@ -473,7 +495,7 @@ describe('CallImagesModal', () => {
]
};
- mockUseCallDetailStore.mockReturnValue(invalidImagesStore as any);
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any);
const { getByTestId, queryByTestId } = render();
@@ -682,7 +704,7 @@ describe('CallImagesModal', () => {
]
};
- mockUseCallDetailStore.mockReturnValue(invalidImagesStore as any);
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any);
const { getByTestId, queryByTestId } = render();
@@ -753,7 +775,7 @@ describe('CallImagesModal', () => {
],
};
- mockUseCallDetailStore.mockReturnValue(mockImagesStore as any);
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockImagesStore as any) : mockImagesStore as any);
render();
@@ -767,7 +789,10 @@ describe('CallImagesModal', () => {
});
it('should track analytics event with loading state', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isLoadingImages: true,
+ } as any) : {
...mockStore,
isLoadingImages: true,
} as any);
@@ -784,7 +809,10 @@ describe('CallImagesModal', () => {
});
it('should track analytics event with error state', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ errorImages: 'Failed to load images',
+ } as any) : {
...mockStore,
errorImages: 'Failed to load images',
} as any);
@@ -825,7 +853,10 @@ describe('CallImagesModal', () => {
});
it('should track analytics event with no images', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ callImages: [],
+ } as any) : {
...mockStore,
callImages: [],
} as any);
@@ -1040,7 +1071,7 @@ describe('CallImagesModal', () => {
setMapLocked: jest.fn(),
};
- mockUseLocationStore.mockReturnValue(mockLocationStore);
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStore) : mockLocationStore);
// Simulate the upload logic
const uploadParams = {
@@ -1068,7 +1099,7 @@ describe('CallImagesModal', () => {
setMapLocked: jest.fn(),
};
- mockUseLocationStore.mockReturnValue(mockLocationStoreNoLocation);
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStoreNoLocation) : mockLocationStoreNoLocation);
// Simulate the upload logic
const uploadParams = {
@@ -1096,7 +1127,7 @@ describe('CallImagesModal', () => {
setMapLocked: jest.fn(),
};
- mockUseLocationStore.mockReturnValue(mockLocationStorePartial);
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStorePartial) : mockLocationStorePartial);
// In the actual implementation, this would be handled by the API
// which checks if both latitude and longitude are provided
diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
index 3e1bc4f6..ab584b7d 100644
--- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx
+++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
@@ -242,12 +242,15 @@ describe('CallNotesModal', () => {
},
} as any);
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
});
- mockUseAuthStore.mockReturnValue(mockAuthStore);
+ mockUseAuthStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockAuthStore) : mockAuthStore);
});
it('renders correctly when open', () => {
@@ -282,7 +285,10 @@ describe('CallNotesModal', () => {
it('handles search input correctly', () => {
const mockSearchNotes = jest.fn(() => [mockCallDetailStore.callNotes[0]]);
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ searchNotes: mockSearchNotes,
+ }) : {
...mockCallDetailStore,
searchNotes: mockSearchNotes,
});
@@ -298,7 +304,10 @@ describe('CallNotesModal', () => {
});
it('shows loading state correctly', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ isNotesLoading: true,
+ }) : {
...mockCallDetailStore,
isNotesLoading: true,
});
@@ -309,7 +318,11 @@ describe('CallNotesModal', () => {
});
it('shows zero state when no notes found', () => {
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ callNotes: [],
+ searchNotes: jest.fn(() => []),
+ }) : {
...mockCallDetailStore,
callNotes: [],
searchNotes: jest.fn(() => []),
@@ -322,7 +335,11 @@ describe('CallNotesModal', () => {
it('handles adding a new note', async () => {
const mockAddNote = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ addNote: mockAddNote,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
addNote: mockAddNote,
searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
@@ -343,7 +360,11 @@ describe('CallNotesModal', () => {
it('disables add button when note input is empty', () => {
const mockAddNote = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ addNote: mockAddNote,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
addNote: mockAddNote,
searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
@@ -361,7 +382,12 @@ describe('CallNotesModal', () => {
it('disables add button when loading', () => {
const mockAddNote = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ addNote: mockAddNote,
+ isNotesLoading: true,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
addNote: mockAddNote,
isNotesLoading: true,
@@ -391,7 +417,11 @@ describe('CallNotesModal', () => {
it('clears note input after successful submission', async () => {
const mockAddNote = jest.fn().mockResolvedValue(undefined);
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ addNote: mockAddNote,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
addNote: mockAddNote,
searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
@@ -412,7 +442,11 @@ describe('CallNotesModal', () => {
it('does not add empty note when only whitespace is entered', async () => {
const mockAddNote = jest.fn();
- mockUseCallDetailStore.mockReturnValue({
+ mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockCallDetailStore,
+ addNote: mockAddNote,
+ searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
+ }) : {
...mockCallDetailStore,
addNote: mockAddNote,
searchNotes: jest.fn(() => mockCallDetailStore.callNotes),
@@ -430,7 +464,9 @@ describe('CallNotesModal', () => {
});
it('handles missing user profile gracefully', () => {
- mockUseAuthStore.mockReturnValue({
+ mockUseAuthStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ profile: null,
+ }) : {
profile: null,
});
diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
index cbd17603..d76d7dd4 100644
--- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
@@ -6,7 +6,7 @@ import { DispatchSelectionModal } from '../dispatch-selection-modal';
// Mock dependencies
jest.mock('@/stores/dispatch/store', () => ({
- useDispatchStore: () => ({
+ useDispatchStore: (selector: any) => typeof selector === 'function' ? selector({
data: {
users: [],
groups: [],
@@ -38,7 +38,39 @@ jest.mock('@/stores/dispatch/store', () => ({
roles: [],
units: [],
}),
- }),
+ }) : {
+ data: {
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ selection: {
+ everyone: false,
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ },
+ isLoading: false,
+ error: null,
+ searchQuery: '',
+ fetchDispatchData: jest.fn(),
+ setSelection: jest.fn(),
+ toggleEveryone: jest.fn(),
+ toggleUser: jest.fn(),
+ toggleGroup: jest.fn(),
+ toggleRole: jest.fn(),
+ toggleUnit: jest.fn(),
+ setSearchQuery: jest.fn(),
+ clearSelection: jest.fn(),
+ getFilteredData: () => ({
+ users: [],
+ groups: [],
+ roles: [],
+ units: [],
+ }),
+ },
}));
jest.mock('nativewind', () => ({
diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
index 4cb8a8c3..ccc28128 100644
--- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
@@ -140,7 +140,7 @@ const mockDispatchStore = {
};
jest.mock('@/stores/dispatch/store', () => ({
- useDispatchStore: jest.fn(() => mockDispatchStore),
+ useDispatchStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockDispatchStore) : mockDispatchStore),
}));
// Mock the color scheme and cssInterop
diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx
index ed3f3da2..7dbf7406 100644
--- a/src/components/calls/call-files-modal.tsx
+++ b/src/components/calls/call-files-modal.tsx
@@ -32,7 +32,11 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose,
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const colorScheme = useColorScheme();
- const { callFiles, isLoadingFiles, errorFiles, fetchCallFiles, clearFiles } = useCallDetailStore();
+ const callFiles = useCallDetailStore((state) => state.callFiles);
+ const isLoadingFiles = useCallDetailStore((state) => state.isLoadingFiles);
+ const errorFiles = useCallDetailStore((state) => state.errorFiles);
+ const fetchCallFiles = useCallDetailStore((state) => state.fetchCallFiles);
+ const clearFiles = useCallDetailStore((state) => state.clearFiles);
const [downloadingFiles, setDownloadingFiles] = useState>({});
// Bottom sheet ref and snap points
diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx
index 42bee422..07acf0c6 100644
--- a/src/components/calls/call-images-modal.tsx
+++ b/src/components/calls/call-images-modal.tsx
@@ -39,7 +39,8 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const { colorScheme } = useColorScheme();
- const { latitude, longitude } = useLocationStore();
+ const latitude = useLocationStore((state) => state.latitude);
+ const longitude = useLocationStore((state) => state.longitude);
const isDark = colorScheme === 'dark';
@@ -52,7 +53,12 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call
const [fullScreenImage, setFullScreenImage] = useState<{ source: any; name?: string } | null>(null);
const flatListRef = useRef>(null);
- const { callImages, isLoadingImages, errorImages, fetchCallImages, uploadCallImage, clearImages } = useCallDetailStore();
+ const callImages = useCallDetailStore((state) => state.callImages);
+ const isLoadingImages = useCallDetailStore((state) => state.isLoadingImages);
+ const errorImages = useCallDetailStore((state) => state.errorImages);
+ const fetchCallImages = useCallDetailStore((state) => state.fetchCallImages);
+ const uploadCallImage = useCallDetailStore((state) => state.uploadCallImage);
+ const clearImages = useCallDetailStore((state) => state.clearImages);
// Filter out images without proper data or URL
const validImages = useMemo(() => {
diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx
index f38c5c09..33048baa 100644
--- a/src/components/calls/call-notes-modal.tsx
+++ b/src/components/calls/call-notes-modal.tsx
@@ -32,8 +32,12 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => {
const { colorScheme } = useColorScheme();
const [searchQuery, setSearchQuery] = useState('');
const [newNote, setNewNote] = useState('');
- const { callNotes, addNote, searchNotes, isNotesLoading, fetchCallNotes } = useCallDetailStore();
- const { profile } = useAuthStore();
+ const callNotes = useCallDetailStore((state) => state.callNotes);
+ const addNote = useCallDetailStore((state) => state.addNote);
+ const searchNotes = useCallDetailStore((state) => state.searchNotes);
+ const isNotesLoading = useCallDetailStore((state) => state.isNotesLoading);
+ const fetchCallNotes = useCallDetailStore((state) => state.fetchCallNotes);
+ const profile = useAuthStore((state) => state.profile);
const isDark = colorScheme === 'dark';
diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx
index 7a37c2d3..dc00434d 100644
--- a/src/components/calls/close-call-bottom-sheet.tsx
+++ b/src/components/calls/close-call-bottom-sheet.tsx
@@ -29,8 +29,8 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
const router = useRouter();
const showToast = useToastStore((state) => state.showToast);
const { trackEvent } = useAnalytics();
- const { closeCall } = useCallDetailStore();
- const { fetchCalls } = useCallsStore();
+ const closeCall = useCallDetailStore((state) => state.closeCall);
+ const fetchCalls = useCallsStore((state) => state.fetchCalls);
const [closeCallType, setCloseCallType] = useState('');
const [closeCallNote, setCloseCallNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx
index 2a699b55..43ef5ff1 100644
--- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx
+++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx
@@ -263,7 +263,12 @@ describe('ContactDetailsSheet', () => {
jest.clearAllMocks();
// Setup default mock store state
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [mockPersonContact],
+ selectedContactId: 'contact-1',
+ isDetailsOpen: true,
+ closeDetails: jest.fn(),
+ }) : {
contacts: [mockPersonContact],
selectedContactId: 'contact-1',
isDetailsOpen: true,
@@ -296,7 +301,12 @@ describe('ContactDetailsSheet', () => {
CompanyName: 'Acme Corp',
};
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [companyContact],
+ selectedContactId: 'company-1',
+ isDetailsOpen: true,
+ closeDetails: jest.fn(),
+ }) : {
contacts: [companyContact],
selectedContactId: 'company-1',
isDetailsOpen: true,
@@ -320,7 +330,12 @@ describe('ContactDetailsSheet', () => {
});
it('should not track analytics event when sheet is closed', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [mockPersonContact],
+ selectedContactId: 'contact-1',
+ isDetailsOpen: false,
+ closeDetails: jest.fn(),
+ }) : {
contacts: [mockPersonContact],
selectedContactId: 'contact-1',
isDetailsOpen: false,
@@ -333,7 +348,12 @@ describe('ContactDetailsSheet', () => {
});
it('should not track analytics event when no contact is selected', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [mockPersonContact],
+ selectedContactId: null,
+ isDetailsOpen: true,
+ closeDetails: jest.fn(),
+ }) : {
contacts: [mockPersonContact],
selectedContactId: null,
isDetailsOpen: true,
@@ -365,7 +385,12 @@ describe('ContactDetailsSheet', () => {
Notes: undefined,
};
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contacts: [minimalContact],
+ selectedContactId: 'minimal-1',
+ isDetailsOpen: true,
+ closeDetails: jest.fn(),
+ }) : {
contacts: [minimalContact],
selectedContactId: 'minimal-1',
isDetailsOpen: true,
diff --git a/src/components/contacts/__tests__/contact-notes-list.test.tsx b/src/components/contacts/__tests__/contact-notes-list.test.tsx
index 11b05ff0..aa895678 100644
--- a/src/components/contacts/__tests__/contact-notes-list.test.tsx
+++ b/src/components/contacts/__tests__/contact-notes-list.test.tsx
@@ -56,7 +56,42 @@ describe('ContactNotesList', () => {
jest.clearAllMocks();
// Default mock store state
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {
+ 'test-contact-123': [
+ {
+ ContactNoteId: 'note-1',
+ ContactId: 'test-contact-123',
+ Note: 'This is a test note',
+ NoteType: 'General',
+ Visibility: 1, // Public
+ ShouldAlert: false,
+ AddedBy: 'user-1',
+ AddedByName: 'John Doe',
+ AddedOn: '2023-01-15T10:30:00Z',
+ AddedOnUtc: '2023-01-15T10:30:00Z',
+ ExpiresOn: '2024-01-15T10:30:00Z',
+ ExpiresOnUtc: '2024-01-15T10:30:00Z',
+ },
+ {
+ ContactNoteId: 'note-2',
+ ContactId: 'test-contact-123',
+ Note: 'This is another test note',
+ NoteType: 'Important',
+ Visibility: 0, // Internal
+ ShouldAlert: true,
+ AddedBy: 'user-2',
+ AddedByName: 'Jane Smith',
+ AddedOn: '2023-02-15T14:30:00Z',
+ AddedOnUtc: '2023-02-15T14:30:00Z',
+ ExpiresOn: null,
+ ExpiresOnUtc: null,
+ },
+ ],
+ },
+ isNotesLoading: false,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {
'test-contact-123': [
{
@@ -104,7 +139,11 @@ describe('ContactNotesList', () => {
});
it('should render loading state', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {},
+ isNotesLoading: true,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {},
isNotesLoading: true,
fetchContactNotes: mockFetchContactNotes,
@@ -116,7 +155,13 @@ describe('ContactNotesList', () => {
});
it('should render empty state when no notes', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {
+ 'test-contact-123': [],
+ },
+ isNotesLoading: false,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {
'test-contact-123': [],
},
@@ -144,7 +189,13 @@ describe('ContactNotesList', () => {
});
it('should track analytics event when component is rendered without notes', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {
+ 'test-contact-123': [],
+ },
+ isNotesLoading: false,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {
'test-contact-123': [],
},
@@ -163,7 +214,11 @@ describe('ContactNotesList', () => {
});
it('should track analytics event when component is in loading state', () => {
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {},
+ isNotesLoading: true,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {},
isNotesLoading: true,
fetchContactNotes: mockFetchContactNotes,
@@ -189,7 +244,11 @@ describe('ContactNotesList', () => {
const { rerender } = render();
// Mock empty notes for contact-1
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {},
+ isNotesLoading: false,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {},
isNotesLoading: false,
fetchContactNotes: mockFetchContactNotes,
@@ -203,7 +262,28 @@ describe('ContactNotesList', () => {
});
// Mock different notes for the new contact
- mockUseContactsStore.mockReturnValue({
+ mockUseContactsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ contactNotes: {
+ 'contact-2': [
+ {
+ ContactNoteId: 'note-3',
+ ContactId: 'contact-2',
+ Note: 'Note for contact 2',
+ NoteType: 'General',
+ Visibility: 1,
+ ShouldAlert: false,
+ AddedBy: 'user-1',
+ AddedByName: 'John Doe',
+ AddedOn: '2023-01-15T10:30:00Z',
+ AddedOnUtc: '2023-01-15T10:30:00Z',
+ ExpiresOn: null,
+ ExpiresOnUtc: null,
+ },
+ ],
+ },
+ isNotesLoading: false,
+ fetchContactNotes: mockFetchContactNotes,
+ }) : {
contactNotes: {
'contact-2': [
{
diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx
index 227e9868..cefff63a 100644
--- a/src/components/contacts/contact-details-sheet.tsx
+++ b/src/components/contacts/contact-details-sheet.tsx
@@ -176,7 +176,10 @@ export const ContactDetailsSheet: React.FC = () => {
const { trackEvent } = useAnalytics();
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
- const { contacts, selectedContactId, isDetailsOpen, closeDetails } = useContactsStore();
+ const contacts = useContactsStore((s) => s.contacts);
+ const selectedContactId = useContactsStore((s) => s.selectedContactId);
+ const isDetailsOpen = useContactsStore((s) => s.isDetailsOpen);
+ const closeDetails = useContactsStore((s) => s.closeDetails);
const [activeTab, setActiveTab] = useState<'details' | 'notes'>('details');
const selectedContact = React.useMemo(() => {
diff --git a/src/components/contacts/contact-notes-list.tsx b/src/components/contacts/contact-notes-list.tsx
index e7bbf55f..fcfc3572 100644
--- a/src/components/contacts/contact-notes-list.tsx
+++ b/src/components/contacts/contact-notes-list.tsx
@@ -236,7 +236,9 @@ const ContactNoteCard: React.FC = ({ note }) => {
export const ContactNotesList: React.FC = ({ contactId }) => {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
- const { contactNotes, isNotesLoading, fetchContactNotes } = useContactsStore();
+ const contactNotes = useContactsStore((s) => s.contactNotes);
+ const isNotesLoading = useContactsStore((s) => s.isNotesLoading);
+ const fetchContactNotes = useContactsStore((s) => s.fetchContactNotes);
React.useEffect(() => {
if (contactId) {
diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
index 2073fc38..4483df0f 100644
--- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
+++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
@@ -135,8 +135,8 @@ const mockSelectedAudioDevices = {
};
describe('LiveKitBottomSheet', () => {
- const mockUseLiveKitStore = useLiveKitStore as jest.MockedFunction;
- const mockUseBluetoothAudioStore = useBluetoothAudioStore as jest.MockedFunction;
+ const mockUseLiveKitStore = useLiveKitStore as unknown as jest.Mock;
+ const mockUseBluetoothAudioStore = useBluetoothAudioStore as unknown as jest.Mock;
const defaultLiveKitState = {
isBottomSheetVisible: false,
@@ -159,13 +159,16 @@ describe('LiveKitBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseLiveKitStore.mockReturnValue(defaultLiveKitState);
- mockUseBluetoothAudioStore.mockReturnValue(defaultBluetoothState);
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultLiveKitState) : defaultLiveKitState);
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultBluetoothState) : defaultBluetoothState);
});
describe('Component Rendering', () => {
it('should render successfully when bottom sheet is not visible', () => {
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: false,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: false,
});
@@ -176,7 +179,12 @@ describe('LiveKitBottomSheet', () => {
it('should render successfully when bottom sheet is visible', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: mockAvailableRooms,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: mockAvailableRooms,
@@ -190,7 +198,12 @@ describe('LiveKitBottomSheet', () => {
it('should render successfully when connecting', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnecting: true,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnecting: true,
@@ -204,7 +217,14 @@ describe('LiveKitBottomSheet', () => {
it('should render successfully when connected', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -222,7 +242,11 @@ describe('LiveKitBottomSheet', () => {
describe('Store Interactions', () => {
it('should call fetchVoiceSettings when opening room selection view', () => {
const mockFetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ fetchVoiceSettings: mockFetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
fetchVoiceSettings: mockFetchVoiceSettings,
@@ -234,7 +258,12 @@ describe('LiveKitBottomSheet', () => {
it('should handle empty rooms list', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: [],
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: [],
@@ -248,7 +277,14 @@ describe('LiveKitBottomSheet', () => {
it('should handle connected state with room info', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -264,7 +300,15 @@ describe('LiveKitBottomSheet', () => {
it('should handle talking state', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ isTalking: true,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -283,7 +327,14 @@ describe('LiveKitBottomSheet', () => {
describe('Audio Device State', () => {
it('should handle missing microphone device', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -292,7 +343,12 @@ describe('LiveKitBottomSheet', () => {
fetchVoiceSettings,
});
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ selectedAudioDevices: {
+ microphone: null,
+ speaker: mockSelectedAudioDevices.speaker,
+ },
+ }) : {
selectedAudioDevices: {
microphone: null,
speaker: mockSelectedAudioDevices.speaker,
@@ -305,7 +361,14 @@ describe('LiveKitBottomSheet', () => {
it('should handle missing speaker device', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -314,7 +377,12 @@ describe('LiveKitBottomSheet', () => {
fetchVoiceSettings,
});
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ selectedAudioDevices: {
+ microphone: mockSelectedAudioDevices.microphone,
+ speaker: null,
+ },
+ }) : {
selectedAudioDevices: {
microphone: mockSelectedAudioDevices.microphone,
speaker: null,
@@ -329,7 +397,14 @@ describe('LiveKitBottomSheet', () => {
describe('Edge Cases', () => {
it('should handle missing currentRoom gracefully', () => {
const fetchVoiceSettings = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: null,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -348,7 +423,14 @@ describe('LiveKitBottomSheet', () => {
localParticipant: null,
};
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: roomWithoutParticipant,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -368,7 +450,14 @@ describe('LiveKitBottomSheet', () => {
Name: '',
};
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: roomInfoWithoutName,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -387,7 +476,12 @@ describe('LiveKitBottomSheet', () => {
const fetchVoiceSettings = jest.fn();
// Start with room selection view
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: mockAvailableRooms,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: mockAvailableRooms,
@@ -399,7 +493,14 @@ describe('LiveKitBottomSheet', () => {
// Connect to room - fetchVoiceSettings should not be called again since we're now in connected view
fetchVoiceSettings.mockClear();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -416,7 +517,14 @@ describe('LiveKitBottomSheet', () => {
const fetchVoiceSettings = jest.fn();
// Start with muted microphone in connected state
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -437,7 +545,14 @@ describe('LiveKitBottomSheet', () => {
},
};
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: enabledMockRoom,
+ fetchVoiceSettings,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -468,7 +583,11 @@ describe('LiveKitBottomSheet', () => {
it('should return to room select when back is pressed from audio settings if entered from room select', () => {
const { fireEvent } = require('@testing-library/react-native');
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: mockAvailableRooms,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: mockAvailableRooms,
@@ -494,7 +613,13 @@ describe('LiveKitBottomSheet', () => {
it('should return to connected view when back is pressed from audio settings if entered from connected view', async () => {
const { fireEvent } = require('@testing-library/react-native');
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ currentRoom: mockRoom,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
@@ -526,17 +651,19 @@ describe('LiveKitBottomSheet', () => {
describe('Analytics', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseLiveKitStore.mockReturnValue(defaultLiveKitState);
- mockUseBluetoothAudioStore.mockReturnValue(defaultBluetoothState);
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultLiveKitState) : defaultLiveKitState);
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultBluetoothState) : defaultBluetoothState);
});
it('should track analytics event when bottom sheet is opened', () => {
- const requestPermissions = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: mockAvailableRooms,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: mockAvailableRooms,
- requestPermissions,
});
render();
@@ -552,12 +679,14 @@ describe('LiveKitBottomSheet', () => {
isTalking: false,
hasBluetoothMicrophone: false,
hasBluetoothSpeaker: false,
- permissionsRequested: false,
});
});
it('should not track analytics event when bottom sheet is closed', () => {
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: false,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: false,
});
@@ -568,15 +697,20 @@ describe('LiveKitBottomSheet', () => {
});
it('should track analytics event with connected state', () => {
- const requestPermissions = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ isConnected: true,
+ currentRoomInfo: mockCurrentRoomInfo,
+ availableRooms: mockAvailableRooms,
+ isTalking: true,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
isConnected: true,
currentRoomInfo: mockCurrentRoomInfo,
availableRooms: mockAvailableRooms,
isTalking: true,
- requestPermissions,
});
render();
@@ -592,25 +726,28 @@ describe('LiveKitBottomSheet', () => {
isTalking: true,
hasBluetoothMicrophone: false,
hasBluetoothSpeaker: false,
- permissionsRequested: false,
});
});
it('should track analytics event with bluetooth devices', () => {
- const requestPermissions = jest.fn();
const bluetoothAudioDevices = {
microphone: { id: 'bt-mic', name: 'Bluetooth Mic', type: 'bluetooth' as const, isAvailable: true },
speaker: { id: 'bt-speaker', name: 'Bluetooth Speaker', type: 'bluetooth' as const, isAvailable: true },
};
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultLiveKitState,
+ isBottomSheetVisible: true,
+ availableRooms: mockAvailableRooms,
+ }) : {
...defaultLiveKitState,
isBottomSheetVisible: true,
availableRooms: mockAvailableRooms,
- requestPermissions,
});
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ selectedAudioDevices: bluetoothAudioDevices,
+ }) : {
selectedAudioDevices: bluetoothAudioDevices,
});
@@ -627,7 +764,6 @@ describe('LiveKitBottomSheet', () => {
isTalking: false,
hasBluetoothMicrophone: true,
hasBluetoothSpeaker: true,
- permissionsRequested: false,
});
});
});
diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx
index 67852c58..88e1fdc8 100644
--- a/src/components/livekit/livekit-bottom-sheet.tsx
+++ b/src/components/livekit/livekit-bottom-sheet.tsx
@@ -24,17 +24,25 @@ export enum BottomSheetView {
}
export const LiveKitBottomSheet = () => {
- const { isBottomSheetVisible, setIsBottomSheetVisible, availableRooms, fetchVoiceSettings, connectToRoom, disconnectFromRoom, currentRoomInfo, currentRoom, isConnected, isConnecting, isTalking, requestPermissions } =
- useLiveKitStore();
-
- const { selectedAudioDevices } = useBluetoothAudioStore();
+ const isBottomSheetVisible = useLiveKitStore((s) => s.isBottomSheetVisible);
+ const setIsBottomSheetVisible = useLiveKitStore((s) => s.setIsBottomSheetVisible);
+ const availableRooms = useLiveKitStore((s) => s.availableRooms);
+ const fetchVoiceSettings = useLiveKitStore((s) => s.fetchVoiceSettings);
+ const connectToRoom = useLiveKitStore((s) => s.connectToRoom);
+ const disconnectFromRoom = useLiveKitStore((s) => s.disconnectFromRoom);
+ const currentRoomInfo = useLiveKitStore((s) => s.currentRoomInfo);
+ const currentRoom = useLiveKitStore((s) => s.currentRoom);
+ const isConnected = useLiveKitStore((s) => s.isConnected);
+ const isConnecting = useLiveKitStore((s) => s.isConnecting);
+ const isTalking = useLiveKitStore((s) => s.isTalking);
+
+ const selectedAudioDevices = useBluetoothAudioStore((s) => s.selectedAudioDevices);
const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const [currentView, setCurrentView] = useState(BottomSheetView.ROOM_SELECT);
const [previousView, setPreviousView] = useState(null);
const [isMuted, setIsMuted] = useState(true); // Default to muted
- const [permissionsRequested, setPermissionsRequested] = useState(false);
// Use ref to track if component is mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
@@ -60,68 +68,12 @@ export const LiveKitBottomSheet = () => {
isTalking: isTalking,
hasBluetoothMicrophone: selectedAudioDevices?.microphone?.type === 'bluetooth',
hasBluetoothSpeaker: selectedAudioDevices?.speaker?.type === 'bluetooth',
- permissionsRequested: permissionsRequested,
});
}
- }, [
- isBottomSheetVisible,
- trackEvent,
- availableRooms.length,
- isConnected,
- isConnecting,
- currentView,
- currentRoomInfo,
- isMuted,
- isTalking,
- selectedAudioDevices?.microphone?.type,
- selectedAudioDevices?.speaker?.type,
- permissionsRequested,
- ]);
-
- // Request permissions when the component becomes visible
- useEffect(() => {
- if (isBottomSheetVisible && !permissionsRequested && isMountedRef.current) {
- // Check if we're in a test environment
- const isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
-
- if (isTestEnvironment) {
- // In tests, handle permissions synchronously to avoid act warnings
- try {
- // Call requestPermissions but don't await it in tests
- const result = requestPermissions();
- // Only call .catch if the result is a promise
- if (result && typeof result.catch === 'function') {
- result.catch(() => {
- // Silently handle any errors in test environment
- });
- }
- setPermissionsRequested(true);
- } catch (error) {
- console.error('Failed to request permissions:', error);
- }
- } else {
- // In production, use the async approach with timeout
- const timeoutId = setTimeout(async () => {
- if (isMountedRef.current && !permissionsRequested) {
- try {
- await requestPermissions();
- if (isMountedRef.current) {
- setPermissionsRequested(true);
- }
- } catch (error) {
- if (isMountedRef.current) {
- console.error('Failed to request permissions:', error);
- }
- }
- }
- }, 0);
-
- return () => {
- clearTimeout(timeoutId);
- };
- }
- }
- }, [isBottomSheetVisible, permissionsRequested, requestPermissions]);
+ }, [isBottomSheetVisible, trackEvent, availableRooms.length, isConnected, isConnecting, currentView, currentRoomInfo, isMuted, isTalking, selectedAudioDevices?.microphone?.type, selectedAudioDevices?.speaker?.type]);
+
+ // Note: Permissions are now requested in connectToRoom when the user actually tries to join a voice call
+ // This ensures permissions are granted before the Android foreground service starts
// Sync mute state with LiveKit room
useEffect(() => {
diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx
index c01588b7..9d295138 100644
--- a/src/components/maps/map-pins.tsx
+++ b/src/components/maps/map-pins.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import Mapbox from '@/components/maps/mapbox';
import { type MAP_ICONS } from '@/constants/map-icons';
@@ -13,16 +13,29 @@ interface MapPinsProps {
onPinPress?: (pin: MapMakerInfoData) => void;
}
+// Individual pin wrapper to keep stable onPress callbacks per pin
+const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void }) => {
+ const handlePress = useCallback(() => {
+ onPinPress?.(pin);
+ }, [onPinPress, pin]);
+
+ return (
+
+
+
+ );
+});
+
+MapPin.displayName = 'MapPin';
+
const MapPins: React.FC = ({ pins, onPinPress }) => {
return (
<>
{pins.map((pin) => (
-
- onPinPress?.(pin)} />
-
+
))}
>
);
};
-export default MapPins;
+export default React.memo(MapPins);
diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx
index 27d0124a..f6faf427 100644
--- a/src/components/maps/map-view.web.tsx
+++ b/src/components/maps/map-view.web.tsx
@@ -6,7 +6,8 @@ import 'mapbox-gl/dist/mapbox-gl.css';
import mapboxgl from 'mapbox-gl';
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
-import ReactDOM from 'react-dom';
+// @ts-ignore - react-dom/client types may not be available
+import { createRoot } from 'react-dom/client';
import { Env } from '@/lib/env';
@@ -118,8 +119,12 @@ export const MapView = forwardRef(
onDidFinishLoadingMap?.();
});
- newMap.on('moveend', () => {
- onCameraChanged?.({ properties: { isUserInteraction: true } });
+ newMap.on('moveend', (e: any) => {
+ // mapbox-gl propagates eventData from easeTo/flyTo into the event object.
+ // We tag all programmatic camera moves with { _programmatic: true } so the
+ // moveend handler can distinguish them from real user interactions.
+ const wasUser = !e._programmatic;
+ onCameraChanged?.({ properties: { isUserInteraction: wasUser } });
});
map.current = newMap;
@@ -172,17 +177,20 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve
setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => {
if (!map) return;
- map.easeTo({
- center: options.centerCoordinate,
- zoom: options.zoomLevel,
- bearing: options.heading,
- pitch: options.pitch,
- duration: options.animationDuration || 1000,
- });
+ map.easeTo(
+ {
+ center: options.centerCoordinate,
+ zoom: options.zoomLevel,
+ bearing: options.heading,
+ pitch: options.pitch,
+ duration: options.animationDuration || 1000,
+ },
+ { _programmatic: true }
+ );
},
flyTo: (options: any) => {
if (!map) return;
- map.flyTo(options);
+ map.flyTo(options, { _programmatic: true });
},
}));
@@ -190,13 +198,16 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve
if (!map) return;
if (centerCoordinate) {
- map.easeTo({
- center: centerCoordinate,
- zoom: zoomLevel,
- bearing: heading,
- pitch: pitch,
- duration: animationDuration,
- });
+ map.easeTo(
+ {
+ center: centerCoordinate,
+ zoom: zoomLevel,
+ bearing: heading,
+ pitch: pitch,
+ duration: animationDuration,
+ },
+ { _programmatic: true }
+ );
}
}, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration]);
@@ -255,6 +266,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate
const containerRef = useRef(null);
const containerRootRef = useRef(null);
+ // Create marker once when map/id/coordinate are available
useEffect(() => {
if (!map || !coordinate) return;
@@ -263,12 +275,9 @@ export const PointAnnotation: React.FC = ({ id, coordinate
container.style.cursor = 'pointer';
containerRef.current = container;
- // Render React children into the container using createRoot
- if (children) {
- const root = (ReactDOM as any).createRoot(container);
- root.render(<>{children}>);
- containerRootRef.current = root;
- }
+ // Create a persistent React root for rendering children
+ const root = createRoot(container);
+ containerRootRef.current = root;
// Determine marker options based on anchor prop
const markerOptions: any = {
@@ -284,9 +293,6 @@ export const PointAnnotation: React.FC = ({ id, coordinate
// If anchor is an {x, y} object, convert to pixel offset
if (typeof anchor === 'object' && anchor !== null && 'x' in anchor && 'y' in anchor) {
- // Calculate offset based on container size
- // Mapbox expects offset in pixels, anchor is typically 0-1 range
- // Convert anchor position to offset (center is anchor {0.5, 0.5})
const rect = container.getBoundingClientRect();
const xOffset = (anchor.x - 0.5) * rect.width;
const yOffset = (anchor.y - 0.5) * rect.height;
@@ -297,35 +303,48 @@ export const PointAnnotation: React.FC = ({ id, coordinate
markerRef.current.setPopup(new mapboxgl.Popup().setText(title));
}
- // Attach click listener to container
- if (onSelected && containerRef.current) {
- containerRef.current.addEventListener('click', onSelected);
- }
-
return () => {
- // Clean up click listener
- if (onSelected && containerRef.current) {
- containerRef.current.removeEventListener('click', onSelected);
- }
-
- // Unmount React children
- if (children && containerRootRef.current) {
+ // Unmount React root
+ if (containerRootRef.current) {
containerRootRef.current.unmount();
containerRootRef.current = null;
}
// Remove marker from map
markerRef.current?.remove();
+ markerRef.current = null;
+ containerRef.current = null;
};
+ // Only recreate marker when map, id, or anchor change — NOT children
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [map, coordinate, id, children, anchor, onSelected, title]);
+ }, [map, id]);
- // Update position when coordinate changes
+ // Update coordinate when values actually change (by value, not reference)
useEffect(() => {
if (markerRef.current && coordinate) {
markerRef.current.setLngLat(coordinate);
}
- }, [coordinate]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [coordinate?.[0], coordinate?.[1]]);
+
+ // Render children into the marker's React root whenever children identity changes.
+ // Using a layout effect with [children] dep so it only fires when children actually change.
+ useEffect(() => {
+ if (containerRootRef.current && children) {
+ containerRootRef.current.render(<>{children}>);
+ }
+ }, [children]);
+
+ // Update click handler
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !onSelected) return;
+
+ container.addEventListener('click', onSelected);
+ return () => {
+ container.removeEventListener('click', onSelected);
+ };
+ }, [onSelected]);
return null;
};
diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx
index eb69c6bd..f5f99857 100644
--- a/src/components/maps/pin-detail-modal.tsx
+++ b/src/components/maps/pin-detail-modal.tsx
@@ -30,10 +30,8 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
const { colorScheme } = useColorScheme();
const router = useRouter();
const showToast = useToastStore((state) => state.showToast);
- const userLocation = useLocationStore((state) => ({
- latitude: state.latitude,
- longitude: state.longitude,
- }));
+ const userLatitude = useLocationStore((state) => state.latitude);
+ const userLongitude = useLocationStore((state) => state.longitude);
if (!pin) return null;
@@ -46,7 +44,7 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
}
try {
- const success = await openMapsWithDirections(pin.Latitude, pin.Longitude, pin.Title, userLocation.latitude || undefined, userLocation.longitude || undefined);
+ const success = await openMapsWithDirections(pin.Latitude, pin.Longitude, pin.Title, userLatitude || undefined, userLongitude || undefined);
if (!success) {
showToast('error', t('map.failed_to_open_maps'));
diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx
index 7de982ec..06183ebc 100644
--- a/src/components/maps/pin-marker.tsx
+++ b/src/components/maps/pin-marker.tsx
@@ -14,7 +14,7 @@ interface PinMarkerProps {
onPress?: () => void;
}
-const PinMarker: React.FC = ({ imagePath, title, size = 32, onPress }) => {
+const PinMarker: React.FC = React.memo(({ imagePath, title, size = 32, onPress }) => {
const { colorScheme } = useColorScheme();
const icon = (imagePath && MAP_ICONS[imagePath.toLowerCase() as MapIconKey]) || MAP_ICONS['call'];
@@ -27,7 +27,9 @@ const PinMarker: React.FC = ({ imagePath, title, size = 32, onPr
);
-};
+});
+
+PinMarker.displayName = 'PinMarker';
const styles = StyleSheet.create({
container: {
diff --git a/src/components/notes/__tests__/note-details-sheet.test.tsx b/src/components/notes/__tests__/note-details-sheet.test.tsx
index ba21c305..347aae4f 100644
--- a/src/components/notes/__tests__/note-details-sheet.test.tsx
+++ b/src/components/notes/__tests__/note-details-sheet.test.tsx
@@ -59,7 +59,13 @@ describe('NoteDetailsSheet', () => {
};
// Setup store mock
- require('@/stores/notes/store').useNotesStore.mockReturnValue({
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ notes: [mockNote],
+ selectedNoteId: 'note-123',
+ isDetailsOpen: true,
+ closeDetails: mockCloseDetails,
+ deleteNote: mockDeleteNote,
+ }) : {
notes: [mockNote],
selectedNoteId: 'note-123',
isDetailsOpen: true,
@@ -88,7 +94,13 @@ describe('NoteDetailsSheet', () => {
};
// Setup store mock with closed sheet
- require('@/stores/notes/store').useNotesStore.mockReturnValue({
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ notes: [mockNote],
+ selectedNoteId: 'note-123',
+ isDetailsOpen: false,
+ closeDetails: mockCloseDetails,
+ deleteNote: mockDeleteNote,
+ }) : {
notes: [mockNote],
selectedNoteId: 'note-123',
isDetailsOpen: false,
@@ -103,7 +115,13 @@ describe('NoteDetailsSheet', () => {
it('should not track analytics event when no note is selected', () => {
// Setup store mock with no selected note
- require('@/stores/notes/store').useNotesStore.mockReturnValue({
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ notes: [],
+ selectedNoteId: null,
+ isDetailsOpen: true,
+ closeDetails: mockCloseDetails,
+ deleteNote: mockDeleteNote,
+ }) : {
notes: [],
selectedNoteId: null,
isDetailsOpen: true,
@@ -126,7 +144,13 @@ describe('NoteDetailsSheet', () => {
};
// Setup store mock
- require('@/stores/notes/store').useNotesStore.mockReturnValue({
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ notes: [minimalNote],
+ selectedNoteId: 'note-minimal',
+ isDetailsOpen: true,
+ closeDetails: mockCloseDetails,
+ deleteNote: mockDeleteNote,
+ }) : {
notes: [minimalNote],
selectedNoteId: 'note-minimal',
isDetailsOpen: true,
@@ -163,7 +187,7 @@ describe('NoteDetailsSheet', () => {
deleteNote: mockDeleteNote,
};
- require('@/stores/notes/store').useNotesStore.mockReturnValue(mockStore);
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore) : mockStore);
const { rerender } = render();
@@ -172,7 +196,7 @@ describe('NoteDetailsSheet', () => {
// Update store to opened state
mockStore.isDetailsOpen = true;
- require('@/stores/notes/store').useNotesStore.mockReturnValue(mockStore);
+ require('@/stores/notes/store').useNotesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore) : mockStore);
rerender();
diff --git a/src/components/notes/note-details-sheet.tsx b/src/components/notes/note-details-sheet.tsx
index c70ac1ca..0c11144b 100644
--- a/src/components/notes/note-details-sheet.tsx
+++ b/src/components/notes/note-details-sheet.tsx
@@ -22,7 +22,11 @@ export const NoteDetailsSheet: React.FC = () => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
- const { notes, selectedNoteId, isDetailsOpen, closeDetails, deleteNote } = useNotesStore();
+ const notes = useNotesStore((s) => s.notes);
+ const selectedNoteId = useNotesStore((s) => s.selectedNoteId);
+ const isDetailsOpen = useNotesStore((s) => s.isDetailsOpen);
+ const closeDetails = useNotesStore((s) => s.closeDetails);
+ const deleteNote = useNotesStore((s) => s.deleteNote);
const selectedNote = notes.find((note) => note.NoteId === selectedNoteId);
diff --git a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
index b4e28836..fac68c20 100644
--- a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
+++ b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx
@@ -46,7 +46,7 @@ const mockProtocolsStore = {
};
jest.mock('@/stores/protocols/store', () => ({
- useProtocolsStore: () => mockProtocolsStore,
+ useProtocolsStore: (selector: any) => typeof selector === 'function' ? selector(mockProtocolsStore) : mockProtocolsStore,
}));
// Mock the UI components
diff --git a/src/components/protocols/protocol-details-sheet.tsx b/src/components/protocols/protocol-details-sheet.tsx
index 793583b7..d99978c3 100644
--- a/src/components/protocols/protocol-details-sheet.tsx
+++ b/src/components/protocols/protocol-details-sheet.tsx
@@ -20,7 +20,10 @@ import { VStack } from '../ui/vstack';
export const ProtocolDetailsSheet: React.FC = () => {
const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
- const { protocols, selectedProtocolId, isDetailsOpen, closeDetails } = useProtocolsStore();
+ const protocols = useProtocolsStore((s) => s.protocols);
+ const selectedProtocolId = useProtocolsStore((s) => s.selectedProtocolId);
+ const isDetailsOpen = useProtocolsStore((s) => s.isDetailsOpen);
+ const closeDetails = useProtocolsStore((s) => s.closeDetails);
const selectedProtocol = protocols.find((protocol) => protocol.ProtocolId === selectedProtocolId);
diff --git a/src/components/push-notification/__tests__/push-notification-modal.test.tsx b/src/components/push-notification/__tests__/push-notification-modal.test.tsx
index bda4c6fd..f11fc5f7 100644
--- a/src/components/push-notification/__tests__/push-notification-modal.test.tsx
+++ b/src/components/push-notification/__tests__/push-notification-modal.test.tsx
@@ -108,7 +108,7 @@ describe('PushNotificationModal', () => {
beforeEach(() => {
jest.clearAllMocks();
(useAnalytics as jest.Mock).mockReturnValue(mockAnalytics);
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue(mockStore);
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore) : mockStore);
});
describe('Push Notification Modal', () => {
@@ -128,7 +128,11 @@ describe('PushNotificationModal', () => {
body: 'Structure fire reported at Main St',
};
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ isOpen: true,
+ notification: callNotification,
+ hideNotificationModal: jest.fn(),
+ }) : {
isOpen: true,
notification: callNotification,
hideNotificationModal: jest.fn(),
@@ -154,7 +158,11 @@ describe('PushNotificationModal', () => {
body: 'You have a new message from dispatch',
};
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: messageNotification,
+ }) : {
...mockStore,
isOpen: true,
notification: messageNotification,
@@ -179,7 +187,11 @@ describe('PushNotificationModal', () => {
body: 'New message in chat',
};
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: chatNotification,
+ }) : {
...mockStore,
isOpen: true,
notification: chatNotification,
@@ -204,7 +216,11 @@ describe('PushNotificationModal', () => {
body: 'New message in group chat',
};
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: groupChatNotification,
+ }) : {
...mockStore,
isOpen: true,
notification: groupChatNotification,
@@ -223,7 +239,18 @@ describe('PushNotificationModal', () => {
it('should handle close button press', () => {
const hideNotificationModalMock = jest.fn();
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'call' as const,
+ id: '1234',
+ eventCode: 'C:1234',
+ title: 'Emergency Call',
+ body: 'Structure fire',
+ },
+ hideNotificationModal: hideNotificationModalMock,
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -253,7 +280,18 @@ describe('PushNotificationModal', () => {
it('should handle view call button press', async () => {
const hideNotificationModalMock = jest.fn();
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'call' as const,
+ id: '1234',
+ eventCode: 'C:1234',
+ title: 'Emergency Call',
+ body: 'Structure fire',
+ },
+ hideNotificationModal: hideNotificationModalMock,
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -283,7 +321,17 @@ describe('PushNotificationModal', () => {
});
it('should display correct icon for call notification', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'call' as const,
+ id: '1234',
+ eventCode: 'C:1234',
+ title: 'Emergency Call',
+ body: 'Structure fire',
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -303,7 +351,17 @@ describe('PushNotificationModal', () => {
});
it('should display correct icon for message notification', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'message' as const,
+ id: '5678',
+ eventCode: 'M:5678',
+ title: 'New Message',
+ body: 'Message content',
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -323,7 +381,17 @@ describe('PushNotificationModal', () => {
});
it('should display correct icon for chat notification', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'chat' as const,
+ id: '9101',
+ eventCode: 'T:9101',
+ title: 'Chat Message',
+ body: 'Chat content',
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -343,7 +411,17 @@ describe('PushNotificationModal', () => {
});
it('should display correct icon for group chat notification', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'group-chat' as const,
+ id: '1121',
+ eventCode: 'G:1121',
+ title: 'Group Chat',
+ body: 'Group chat content',
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -363,7 +441,17 @@ describe('PushNotificationModal', () => {
});
it('should display correct icon for unknown notification', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'unknown' as const,
+ id: '9999',
+ eventCode: 'X:9999',
+ title: 'Unknown',
+ body: 'Unknown notification',
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -383,7 +471,17 @@ describe('PushNotificationModal', () => {
});
it('should handle notification without title', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'call' as const,
+ id: '1234',
+ eventCode: 'C:1234',
+ body: 'Structure fire',
+ // No title provided
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
@@ -402,7 +500,17 @@ describe('PushNotificationModal', () => {
});
it('should handle notification without body', () => {
- (usePushNotificationModalStore as unknown as jest.Mock).mockReturnValue({
+ (usePushNotificationModalStore as unknown as jest.Mock).mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...mockStore,
+ isOpen: true,
+ notification: {
+ type: 'call' as const,
+ id: '1234',
+ eventCode: 'C:1234',
+ title: 'Emergency Call',
+ // No body provided
+ },
+ }) : {
...mockStore,
isOpen: true,
notification: {
diff --git a/src/components/push-notification/push-notification-modal.tsx b/src/components/push-notification/push-notification-modal.tsx
index 1f5f2f43..099b39b9 100644
--- a/src/components/push-notification/push-notification-modal.tsx
+++ b/src/components/push-notification/push-notification-modal.tsx
@@ -31,7 +31,9 @@ const NotificationIcon = ({ type }: { type: NotificationType }) => {
export const PushNotificationModal: React.FC = () => {
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
- const { isOpen, notification, hideNotificationModal } = usePushNotificationModalStore();
+ const isOpen = usePushNotificationModalStore((s) => s.isOpen);
+ const notification = usePushNotificationModalStore((s) => s.notification);
+ const hideNotificationModal = usePushNotificationModalStore((s) => s.hideNotificationModal);
const handleClose = () => {
if (notification) {
diff --git a/src/components/roles/__tests__/roles-bottom-sheet.test.tsx b/src/components/roles/__tests__/roles-bottom-sheet.test.tsx
index 2e258bf3..63e746c3 100644
--- a/src/components/roles/__tests__/roles-bottom-sheet.test.tsx
+++ b/src/components/roles/__tests__/roles-bottom-sheet.test.tsx
@@ -150,8 +150,17 @@ describe('RolesBottomSheet', () => {
trackEvent: mockTrackEvent,
});
- mockUseCoreStore.mockReturnValue(mockActiveUnit);
- mockUseRolesStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit });
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: mockRoles,
+ unitRoleAssignments: mockUnitRoleAssignments,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: mockRoles,
unitRoleAssignments: mockUnitRoleAssignments,
users: mockUsers,
@@ -162,7 +171,9 @@ describe('RolesBottomSheet', () => {
assignRoles: mockAssignRoles,
} as any);
- mockUseToastStore.mockReturnValue({
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ showToast: mockShowToast,
+ } as any) : {
showToast: mockShowToast,
} as any);
@@ -209,7 +220,16 @@ describe('RolesBottomSheet', () => {
it('displays error state correctly', () => {
const errorMessage = 'Failed to load roles';
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: [],
+ unitRoleAssignments: [],
+ users: [],
+ isLoading: false,
+ error: errorMessage,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: [],
unitRoleAssignments: [],
users: [],
@@ -226,7 +246,7 @@ describe('RolesBottomSheet', () => {
});
it('handles missing active unit gracefully', () => {
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null });
render();
@@ -244,7 +264,16 @@ describe('RolesBottomSheet', () => {
},
];
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: rolesWithDifferentUnits,
+ unitRoleAssignments: mockUnitRoleAssignments,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: rolesWithDifferentUnits,
unitRoleAssignments: mockUnitRoleAssignments,
users: mockUsers,
@@ -286,7 +315,16 @@ describe('RolesBottomSheet', () => {
},
];
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: rolesWithSameName,
+ unitRoleAssignments: [],
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: rolesWithSameName,
unitRoleAssignments: [],
users: mockUsers,
@@ -325,7 +363,16 @@ describe('RolesBottomSheet', () => {
},
];
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: mockRoles,
+ unitRoleAssignments: assignmentsWithEmpty,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: mockRoles,
unitRoleAssignments: assignmentsWithEmpty,
users: mockUsers,
@@ -393,7 +440,16 @@ describe('RolesBottomSheet', () => {
React.useEffect(() => {
// Override the roles store to include our test pending assignments
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: rolesWithMixedAssignments,
+ unitRoleAssignments: assignmentsWithEmpty,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: rolesWithMixedAssignments,
unitRoleAssignments: assignmentsWithEmpty,
users: mockUsers,
diff --git a/src/components/roles/__tests__/roles-modal.test.tsx b/src/components/roles/__tests__/roles-modal.test.tsx
index 0193f0fe..d9bbdc21 100644
--- a/src/components/roles/__tests__/roles-modal.test.tsx
+++ b/src/components/roles/__tests__/roles-modal.test.tsx
@@ -139,8 +139,17 @@ describe('RolesModal', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseCoreStore.mockReturnValue(mockActiveUnit);
- mockUseRolesStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit });
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: mockRoles,
+ unitRoleAssignments: mockUnitRoleAssignments,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: mockRoles,
unitRoleAssignments: mockUnitRoleAssignments,
users: mockUsers,
@@ -151,7 +160,9 @@ describe('RolesModal', () => {
assignRoles: mockAssignRoles,
} as any);
- mockUseToastStore.mockReturnValue({
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ showToast: mockShowToast,
+ } as any) : {
showToast: mockShowToast,
} as any);
@@ -197,7 +208,16 @@ describe('RolesModal', () => {
it('displays error state correctly', () => {
const errorMessage = 'Failed to load roles';
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: [],
+ unitRoleAssignments: [],
+ users: [],
+ isLoading: false,
+ error: errorMessage,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: [],
unitRoleAssignments: [],
users: [],
@@ -214,7 +234,7 @@ describe('RolesModal', () => {
});
it('handles missing active unit gracefully', () => {
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null });
render();
@@ -231,7 +251,16 @@ describe('RolesModal', () => {
},
];
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ roles: rolesWithDifferentUnits,
+ unitRoleAssignments: mockUnitRoleAssignments,
+ users: mockUsers,
+ isLoading: false,
+ error: null,
+ fetchRolesForUnit: mockFetchRolesForUnit,
+ fetchUsers: mockFetchUsers,
+ assignRoles: mockAssignRoles,
+ } as any) : {
roles: rolesWithDifferentUnits,
unitRoleAssignments: mockUnitRoleAssignments,
users: mockUsers,
diff --git a/src/components/roles/roles-bottom-sheet.tsx b/src/components/roles/roles-bottom-sheet.tsx
index 3728b1b6..18bd90ae 100644
--- a/src/components/roles/roles-bottom-sheet.tsx
+++ b/src/components/roles/roles-bottom-sheet.tsx
@@ -27,7 +27,11 @@ export const RolesBottomSheet: React.FC = ({ isOpen, onCl
const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const activeUnit = useCoreStore((state) => state.activeUnit);
- const { roles, unitRoleAssignments, users, isLoading, error } = useRolesStore();
+ const roles = useRolesStore((state) => state.roles);
+ const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments);
+ const users = useRolesStore((state) => state.users);
+ const isLoading = useRolesStore((state) => state.isLoading);
+ const error = useRolesStore((state) => state.error);
// Add state to track pending changes
const [pendingAssignments, setPendingAssignments] = React.useState<{ roleId: string; userId?: string }[]>([]);
diff --git a/src/components/roles/roles-modal.tsx b/src/components/roles/roles-modal.tsx
index 73a9d831..fe363fb8 100644
--- a/src/components/roles/roles-modal.tsx
+++ b/src/components/roles/roles-modal.tsx
@@ -23,7 +23,11 @@ type RolesModalProps = {
export const RolesModal: React.FC = ({ isOpen, onClose }) => {
const { t } = useTranslation();
const activeUnit = useCoreStore((state) => state.activeUnit);
- const { roles, unitRoleAssignments, users, isLoading, error } = useRolesStore();
+ const roles = useRolesStore((state) => state.roles);
+ const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments);
+ const users = useRolesStore((state) => state.users);
+ const isLoading = useRolesStore((state) => state.isLoading);
+ const error = useRolesStore((state) => state.error);
// Add state to track pending changes
const [pendingAssignments, setPendingAssignments] = React.useState<{ roleId: string; userId?: string }[]>([]);
diff --git a/src/components/settings/__tests__/audio-device-selection.test.tsx b/src/components/settings/__tests__/audio-device-selection.test.tsx
index 8490b725..69dac6b0 100644
--- a/src/components/settings/__tests__/audio-device-selection.test.tsx
+++ b/src/components/settings/__tests__/audio-device-selection.test.tsx
@@ -43,7 +43,7 @@ const mockStore = {
};
jest.mock('@/stores/app/bluetooth-audio-store', () => ({
- useBluetoothAudioStore: () => mockStore,
+ useBluetoothAudioStore: (selector: any) => typeof selector === 'function' ? selector(mockStore) : mockStore,
}));
describe('AudioDeviceSelection', () => {
diff --git a/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx
index 058042e9..b3248a46 100644
--- a/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx
+++ b/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx
@@ -228,7 +228,40 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
// Reset implementations to successful defaults
(bluetoothAudioService.connectToDevice as jest.Mock).mockResolvedValue(undefined);
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [
+ {
+ id: 'test-device-1',
+ name: 'Test Headset',
+ rssi: -50,
+ isConnected: false,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: true,
+ device: {} as any,
+ },
+ {
+ id: 'test-device-2',
+ name: 'Test Speaker',
+ rssi: -70,
+ isConnected: true,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: false,
+ device: {} as any,
+ },
+ ],
+ isScanning: false,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: {
+ id: 'test-device-2',
+ name: 'Test Speaker',
+ rssi: -70,
+ isConnected: true,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: false,
+ device: {} as any,
+ },
+ connectionError: null,
+ } as any) : {
availableDevices: [
{
id: 'test-device-1',
@@ -289,7 +322,13 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
});
it('displays bluetooth state warnings', () => {
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [],
+ isScanning: false,
+ bluetoothState: State.PoweredOff,
+ connectedDevice: null,
+ connectionError: null,
+ } as any) : {
availableDevices: [],
isScanning: false,
bluetoothState: State.PoweredOff,
@@ -303,7 +342,13 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
});
it('displays connection errors', () => {
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [],
+ isScanning: false,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: null,
+ connectionError: 'Failed to connect to device',
+ } as any) : {
availableDevices: [],
isScanning: false,
bluetoothState: State.PoweredOn,
@@ -317,7 +362,13 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
});
it('shows scanning state', () => {
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [],
+ isScanning: true,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: null,
+ connectionError: null,
+ } as any) : {
availableDevices: [],
isScanning: true,
bluetoothState: State.PoweredOn,
@@ -346,7 +397,23 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
device: {} as any,
};
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [
+ {
+ id: 'test-device-1',
+ name: 'Test Headset',
+ rssi: -50,
+ isConnected: false,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: true,
+ device: {} as any,
+ },
+ ],
+ isScanning: false,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: mockConnectedDevice,
+ connectionError: null,
+ } as any) : {
availableDevices: [
{
id: 'test-device-1',
@@ -393,7 +460,23 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
// Make connect fail
(bluetoothAudioService.connectToDevice as jest.Mock).mockRejectedValue(new Error('Connection failed'));
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [
+ {
+ id: 'test-device-1',
+ name: 'Test Headset',
+ rssi: -50,
+ isConnected: false,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: true,
+ device: {} as any,
+ },
+ ],
+ isScanning: false,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: null,
+ connectionError: null,
+ } as any) : {
availableDevices: [
{
id: 'test-device-1',
@@ -432,7 +515,23 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
});
it('processes device selection when no device is currently connected', async () => {
- mockUseBluetoothAudioStore.mockReturnValue({
+ mockUseBluetoothAudioStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ availableDevices: [
+ {
+ id: 'test-device-1',
+ name: 'Test Headset',
+ rssi: -50,
+ isConnected: false,
+ hasAudioCapability: true,
+ supportsMicrophoneControl: true,
+ device: {} as any,
+ },
+ ],
+ isScanning: false,
+ bluetoothState: State.PoweredOn,
+ connectedDevice: null,
+ connectionError: null,
+ } as any) : {
availableDevices: [
{
id: 'test-device-1',
diff --git a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
index c05bcbb1..d25841f2 100644
--- a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
+++ b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
@@ -34,10 +34,13 @@ jest.mock('react-hook-form', () => ({
}));
jest.mock('@/stores/app/server-url-store', () => ({
- useServerUrlStore: () => ({
+ useServerUrlStore: (selector: any) => typeof selector === 'function' ? selector({
getUrl: jest.fn().mockResolvedValue('https://example.com/api/v4'),
setUrl: jest.fn(),
- }),
+ }) : {
+ getUrl: jest.fn().mockResolvedValue('https://example.com/api/v4'),
+ setUrl: jest.fn(),
+ },
}));
jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } }));
diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx
index 3d0cecec..9669cf62 100644
--- a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx
+++ b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx
@@ -5,6 +5,16 @@ jest.mock('react-i18next', () => ({
}),
}));
+// Mock logging module to prevent Platform.OS undefined error
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
// Mock Platform first, before any other imports
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
@@ -233,18 +243,25 @@ describe('UnitSelectionBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: mockUnits[0],
+ setActiveUnit: mockSetActiveUnit,
+ } as any) : {
activeUnit: mockUnits[0],
setActiveUnit: mockSetActiveUnit,
} as any);
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: mockUnits,
+ fetchUnits: mockFetchUnits,
+ isLoading: false,
+ } as any) : {
units: mockUnits,
fetchUnits: mockFetchUnits,
isLoading: false,
} as any);
- mockUseToastStore.mockReturnValue(mockShowToast);
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ showToast: mockShowToast }) : { showToast: mockShowToast });
// Mock the roles store
(useRolesStore.getState as jest.Mock).mockReturnValue({
@@ -268,7 +285,11 @@ describe('UnitSelectionBottomSheet', () => {
});
it('displays loading state when fetching units', () => {
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: jest.fn().mockResolvedValue(undefined),
+ isLoading: true,
+ } as any) : {
units: [],
fetchUnits: jest.fn().mockResolvedValue(undefined),
isLoading: true,
@@ -281,7 +302,11 @@ describe('UnitSelectionBottomSheet', () => {
});
it('displays empty state when no units available', () => {
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: jest.fn().mockResolvedValue(undefined),
+ isLoading: false,
+ } as any) : {
units: [],
fetchUnits: jest.fn().mockResolvedValue(undefined),
isLoading: false,
@@ -295,7 +320,11 @@ describe('UnitSelectionBottomSheet', () => {
it('fetches units when sheet opens and no units are loaded', async () => {
const spyFetchUnits = jest.fn().mockResolvedValue(undefined);
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: spyFetchUnits,
+ isLoading: false,
+ } as any) : {
units: [],
fetchUnits: spyFetchUnits,
isLoading: false,
@@ -416,7 +445,11 @@ describe('UnitSelectionBottomSheet', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
const errorFetchUnits = jest.fn().mockRejectedValue(new Error('Network error'));
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: errorFetchUnits,
+ isLoading: false,
+ } as any) : {
units: [],
fetchUnits: errorFetchUnits,
isLoading: false,
@@ -444,7 +477,10 @@ describe('UnitSelectionBottomSheet', () => {
describe('Edge Cases', () => {
it('handles missing active unit gracefully', () => {
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: null,
+ setActiveUnit: mockSetActiveUnit,
+ } as any) : {
activeUnit: null,
setActiveUnit: mockSetActiveUnit,
} as any);
@@ -481,7 +517,11 @@ describe('UnitSelectionBottomSheet', () => {
} as UnitResultData,
];
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: unitsWithMissingNames,
+ fetchUnits: mockFetchUnits,
+ isLoading: false,
+ } as any) : {
units: unitsWithMissingNames,
fetchUnits: mockFetchUnits,
isLoading: false,
diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx
index 7f4878b4..7bbc1262 100644
--- a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx
+++ b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx
@@ -185,12 +185,19 @@ describe('UnitSelectionBottomSheet Import Test', () => {
const mockUseRolesStore = require('@/stores/roles/store').useRolesStore;
// Minimal mock setup
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: null,
+ setActiveUnit: jest.fn(),
+ }) : {
activeUnit: null,
setActiveUnit: jest.fn(),
});
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: jest.fn().mockResolvedValue(undefined),
+ isLoading: false,
+ }) : {
units: [],
fetchUnits: jest.fn().mockResolvedValue(undefined),
isLoading: false,
@@ -297,12 +304,19 @@ describe('UnitSelectionBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: mockUnits[0],
+ setActiveUnit: mockSetActiveUnit,
+ } as any) : {
activeUnit: mockUnits[0],
setActiveUnit: mockSetActiveUnit,
} as any);
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: mockUnits,
+ fetchUnits: mockFetchUnits,
+ isLoading: false,
+ } as any) : {
units: mockUnits,
fetchUnits: mockFetchUnits,
isLoading: false,
@@ -350,7 +364,11 @@ describe('UnitSelectionBottomSheet', () => {
});
it('displays loading state when fetching units', () => {
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: jest.fn().mockResolvedValue(undefined),
+ isLoading: true,
+ } as any) : {
units: [],
fetchUnits: jest.fn().mockResolvedValue(undefined),
isLoading: true,
@@ -363,7 +381,11 @@ describe('UnitSelectionBottomSheet', () => {
});
it('displays empty state when no units available', () => {
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: jest.fn().mockResolvedValue(undefined),
+ isLoading: false,
+ } as any) : {
units: [],
fetchUnits: jest.fn().mockResolvedValue(undefined),
isLoading: false,
@@ -377,7 +399,11 @@ describe('UnitSelectionBottomSheet', () => {
it('fetches units when sheet opens and no units are loaded', async () => {
const spyFetchUnits = jest.fn().mockResolvedValue(undefined);
- mockUseUnitsStore.mockReturnValue({
+ mockUseUnitsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ units: [],
+ fetchUnits: spyFetchUnits,
+ isLoading: false,
+ } as any) : {
units: [],
fetchUnits: spyFetchUnits,
isLoading: false,
@@ -516,7 +542,10 @@ describe('UnitSelectionBottomSheet', () => {
});
it('renders with no active unit', () => {
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: null,
+ setActiveUnit: mockSetActiveUnit,
+ } as any) : {
activeUnit: null,
setActiveUnit: mockSetActiveUnit,
} as any);
diff --git a/src/components/settings/audio-device-selection.tsx b/src/components/settings/audio-device-selection.tsx
index 065c4035..043c18f6 100644
--- a/src/components/settings/audio-device-selection.tsx
+++ b/src/components/settings/audio-device-selection.tsx
@@ -17,7 +17,10 @@ interface AudioDeviceSelectionProps {
export const AudioDeviceSelection: React.FC = ({ showTitle = true }) => {
const { t } = useTranslation();
- const { availableAudioDevices, selectedAudioDevices, setSelectedMicrophone, setSelectedSpeaker } = useBluetoothAudioStore();
+ const availableAudioDevices = useBluetoothAudioStore((s) => s.availableAudioDevices);
+ const selectedAudioDevices = useBluetoothAudioStore((s) => s.selectedAudioDevices);
+ const setSelectedMicrophone = useBluetoothAudioStore((s) => s.setSelectedMicrophone);
+ const setSelectedSpeaker = useBluetoothAudioStore((s) => s.setSelectedSpeaker);
const renderDeviceIcon = (device: AudioDeviceInfo) => {
switch (device.type) {
diff --git a/src/components/settings/bluetooth-device-item.tsx b/src/components/settings/bluetooth-device-item.tsx
index b5b6d803..e1aee960 100644
--- a/src/components/settings/bluetooth-device-item.tsx
+++ b/src/components/settings/bluetooth-device-item.tsx
@@ -15,7 +15,7 @@ import { BluetoothDeviceSelectionBottomSheet } from './bluetooth-device-selectio
export const BluetoothDeviceItem = () => {
const { t } = useTranslation();
const { preferredDevice } = usePreferredBluetoothDevice();
- const { connectedDevice } = useBluetoothAudioStore();
+ const connectedDevice = useBluetoothAudioStore((s) => s.connectedDevice);
const [showDeviceSelection, setShowDeviceSelection] = useState(false);
const deviceDisplayName = React.useMemo(() => {
diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
index e3be75b8..84f9f8e6 100644
--- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
+++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
@@ -30,7 +30,11 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const { preferredDevice, setPreferredDevice } = usePreferredBluetoothDevice();
- const { availableDevices, isScanning, bluetoothState, connectedDevice, connectionError } = useBluetoothAudioStore();
+ const availableDevices = useBluetoothAudioStore((s) => s.availableDevices);
+ const isScanning = useBluetoothAudioStore((s) => s.isScanning);
+ const bluetoothState = useBluetoothAudioStore((s) => s.bluetoothState);
+ const connectedDevice = useBluetoothAudioStore((s) => s.connectedDevice);
+ const connectionError = useBluetoothAudioStore((s) => s.connectionError);
const [hasScanned, setHasScanned] = useState(false);
const [connectingDeviceId, setConnectingDeviceId] = useState(null);
@@ -273,10 +277,8 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
-
- System Audio
-
- AirPods, Car, Wired Headset
+ {t('bluetooth.systemAudio')}
+ {t('bluetooth.systemAudioDescription')}
{preferredDevice?.id === 'system-audio' && (
diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx
index f29cd53f..b1de750c 100644
--- a/src/components/settings/server-url-bottom-sheet.tsx
+++ b/src/components/settings/server-url-bottom-sheet.tsx
@@ -31,7 +31,8 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const [isLoading, setIsLoading] = React.useState(false);
- const { setUrl, getUrl } = useServerUrlStore();
+ const setUrl = useServerUrlStore((s) => s.setUrl);
+ const getUrl = useServerUrlStore((s) => s.getUrl);
const {
control,
diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx
index 78c08ab4..7b237a34 100644
--- a/src/components/settings/unit-selection-bottom-sheet.tsx
+++ b/src/components/settings/unit-selection-bottom-sheet.tsx
@@ -57,8 +57,11 @@ interface UnitSelectionBottomSheetProps {
export const UnitSelectionBottomSheet = React.memo(({ isOpen, onClose }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = React.useState(false);
- const { units, fetchUnits, isLoading: isLoadingUnits } = useUnitsStore();
- const { activeUnit, setActiveUnit } = useCoreStore();
+ const units = useUnitsStore((state) => state.units);
+ const fetchUnits = useUnitsStore((state) => state.fetchUnits);
+ const isLoadingUnits = useUnitsStore((state) => state.isLoading);
+ const activeUnit = useCoreStore((state) => state.activeUnit);
+ const setActiveUnit = useCoreStore((state) => state.setActiveUnit);
const showToast = useToastStore((state) => state.showToast);
const isProcessingRef = React.useRef(false);
diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx
index 2d064ed1..808bc70d 100644
--- a/src/components/sidebar/__tests__/call-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx
@@ -208,13 +208,20 @@ describe('SidebarCallCard', () => {
toggleColorScheme: jest.fn(),
});
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: null,
+ activePriority: null,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: null,
activePriority: null,
setActiveCall: mockSetActiveCall,
});
- mockUseCallsStore.mockReturnValue({
+ mockUseCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ calls: [],
+ fetchCalls: mockFetchCalls,
+ }) : {
calls: [],
fetchCalls: mockFetchCalls,
});
@@ -243,7 +250,11 @@ describe('SidebarCallCard', () => {
});
it('should render with active call', () => {
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: mockCall,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -258,7 +269,11 @@ describe('SidebarCallCard', () => {
});
it('should show action buttons when active call exists with coordinates', () => {
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: mockCall,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -279,7 +294,11 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -300,7 +319,11 @@ describe('SidebarCallCard', () => {
Address: '',
};
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: callWithoutLocation,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: callWithoutLocation,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -321,7 +344,11 @@ describe('SidebarCallCard', () => {
Address: ' ',
};
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: callWithEmptyAddress,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: callWithEmptyAddress,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -395,7 +422,11 @@ describe('SidebarCallCard', () => {
describe('Action Buttons', () => {
beforeEach(() => {
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: mockCall,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: mockCall,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -447,7 +478,11 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
@@ -486,7 +521,11 @@ describe('SidebarCallCard', () => {
Address: '123 Test Street',
};
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeCall: callWithAddressOnly,
+ activePriority: mockPriority,
+ setActiveCall: mockSetActiveCall,
+ }) : {
activeCall: callWithAddressOnly,
activePriority: mockPriority,
setActiveCall: mockSetActiveCall,
diff --git a/src/components/sidebar/__tests__/roles-sidebar.test.tsx b/src/components/sidebar/__tests__/roles-sidebar.test.tsx
index b8cdb7f2..1f81a60f 100644
--- a/src/components/sidebar/__tests__/roles-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/roles-sidebar.test.tsx
@@ -86,8 +86,8 @@ describe('SidebarRolesCard', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseCoreStore.mockReturnValue(mockActiveUnit);
- mockUseRolesStore.mockReturnValue(mockUnitRoleAssignments);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: mockActiveUnit }) : { activeUnit: mockActiveUnit });
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: mockUnitRoleAssignments }) : { unitRoleAssignments: mockUnitRoleAssignments });
});
it('renders correctly with active unit and role assignments', () => {
@@ -97,7 +97,7 @@ describe('SidebarRolesCard', () => {
});
it('displays zero counts when no active unit', () => {
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null });
render();
@@ -105,7 +105,7 @@ describe('SidebarRolesCard', () => {
});
it('displays zero counts when no role assignments', () => {
- mockUseRolesStore.mockReturnValue([]);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: [] }) : { unitRoleAssignments: [] });
render();
@@ -125,7 +125,7 @@ describe('SidebarRolesCard', () => {
},
];
- mockUseRolesStore.mockReturnValue(roleAssignmentsWithDifferentUnits);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: roleAssignmentsWithDifferentUnits }) : { unitRoleAssignments: roleAssignmentsWithDifferentUnits });
render();
@@ -161,7 +161,7 @@ describe('SidebarRolesCard', () => {
},
];
- mockUseRolesStore.mockReturnValue(testAssignments);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: testAssignments }) : { unitRoleAssignments: testAssignments });
render();
@@ -170,7 +170,7 @@ describe('SidebarRolesCard', () => {
});
it('handles empty unit role assignments gracefully', () => {
- mockUseRolesStore.mockReturnValue([]);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: [] }) : { unitRoleAssignments: [] });
render();
@@ -197,7 +197,7 @@ describe('SidebarRolesCard', () => {
},
];
- mockUseRolesStore.mockReturnValue(assignmentsWithUndefinedFullName);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: assignmentsWithUndefinedFullName }) : { unitRoleAssignments: assignmentsWithUndefinedFullName });
render();
@@ -221,7 +221,7 @@ describe('SidebarRolesCard', () => {
},
];
- mockUseRolesStore.mockReturnValue(newAssignments);
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ unitRoleAssignments: newAssignments }) : { unitRoleAssignments: newAssignments });
rerender();
@@ -240,7 +240,7 @@ describe('SidebarRolesCard', () => {
Name: 'Unit 2',
};
- mockUseCoreStore.mockReturnValue(newUnit);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(newUnit) : newUnit);
rerender();
diff --git a/src/components/sidebar/__tests__/status-sidebar.test.tsx b/src/components/sidebar/__tests__/status-sidebar.test.tsx
index 30b6eb3a..f88ab145 100644
--- a/src/components/sidebar/__tests__/status-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/status-sidebar.test.tsx
@@ -36,7 +36,7 @@ describe('SidebarStatusCard', () => {
});
it('should render with unknown status when activeUnitStatus is null', () => {
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null });
render();
@@ -44,7 +44,7 @@ describe('SidebarStatusCard', () => {
});
it('should render with unknown status when activeUnitStatus is undefined', () => {
- mockUseCoreStore.mockReturnValue(undefined);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: undefined }) : { activeUnitStatus: undefined });
render();
@@ -53,7 +53,7 @@ describe('SidebarStatusCard', () => {
it('should render the correct status text', () => {
const mockStatus = createMockStatus();
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
render();
@@ -62,7 +62,7 @@ describe('SidebarStatusCard', () => {
it('should render status with empty string when State is missing', () => {
const mockStatus = createMockStatus({ State: '' });
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
render();
@@ -89,7 +89,7 @@ describe('SidebarStatusCard', () => {
StateStyle: styleClass,
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
const { getByTestId } = render();
@@ -109,7 +109,7 @@ describe('SidebarStatusCard', () => {
StateStyle: 'unknown-style',
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
const { getByTestId } = render();
@@ -127,7 +127,7 @@ describe('SidebarStatusCard', () => {
StateStyle: '',
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
const { getByTestId } = render();
@@ -143,7 +143,7 @@ describe('SidebarStatusCard', () => {
describe('Status updates', () => {
it('should re-render when activeUnitStatus changes', () => {
const initialStatus = createMockStatus();
- mockUseCoreStore.mockReturnValue(initialStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: initialStatus }) : { activeUnitStatus: initialStatus });
const { rerender } = render();
@@ -157,7 +157,7 @@ describe('SidebarStatusCard', () => {
Note: 'Updated note',
});
- mockUseCoreStore.mockReturnValue(updatedStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: updatedStatus }) : { activeUnitStatus: updatedStatus });
rerender();
@@ -166,7 +166,7 @@ describe('SidebarStatusCard', () => {
});
it('should handle transition from null to valid status', () => {
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null });
const { rerender } = render();
@@ -178,7 +178,7 @@ describe('SidebarStatusCard', () => {
StateStyle: 'label-enroute',
});
- mockUseCoreStore.mockReturnValue(status);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: status }) : { activeUnitStatus: status });
rerender();
@@ -192,14 +192,14 @@ describe('SidebarStatusCard', () => {
StateStyle: 'label-onscene',
});
- mockUseCoreStore.mockReturnValue(status);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: status }) : { activeUnitStatus: status });
const { rerender } = render();
expect(screen.getByText('On Scene')).toBeTruthy();
// Update to null
- mockUseCoreStore.mockReturnValue(null);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: null }) : { activeUnitStatus: null });
rerender();
@@ -215,7 +215,7 @@ describe('SidebarStatusCard', () => {
StateStyle: 'label-info',
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
render();
@@ -229,7 +229,7 @@ describe('SidebarStatusCard', () => {
StateStyle: 'label-info',
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
render();
@@ -242,7 +242,7 @@ describe('SidebarStatusCard', () => {
StateStyle: null as any,
});
- mockUseCoreStore.mockReturnValue(mockStatus);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeUnitStatus: mockStatus }) : { activeUnitStatus: mockStatus });
const { getByTestId } = render();
diff --git a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx
index ecfb19b5..cb6eab37 100644
--- a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx
+++ b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx
@@ -53,28 +53,37 @@ jest.mock('lucide-react-native', () => {
// Mock all stores inline
jest.mock('@/stores/app/core-store', () => ({
- useCoreStore: jest.fn(() => ({ activeUnit: null })),
+ useCoreStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }),
}));
jest.mock('@/stores/app/location-store', () => ({
- useLocationStore: jest.fn(() => ({ isMapLocked: false, setMapLocked: jest.fn() })),
+ useLocationStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() }),
}));
jest.mock('@/stores/app/livekit-store', () => ({
- useLiveKitStore: jest.fn(() => ({
+ useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: jest.fn(),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
- })),
+ }) : {
+ setIsBottomSheetVisible: jest.fn(),
+ currentRoomInfo: null,
+ isConnected: false,
+ isTalking: false,
+ }),
}));
jest.mock('@/stores/app/audio-stream-store', () => ({
- useAudioStreamStore: jest.fn(() => ({
+ useAudioStreamStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: jest.fn(),
+ currentStream: null,
+ isPlaying: false,
+ }) : {
setIsBottomSheetVisible: jest.fn(),
currentStream: null,
isPlaying: false,
- })),
+ }),
}));
jest.mock('@/components/audio-stream/audio-stream-bottom-sheet', () => ({
diff --git a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx
index 1be604b6..c92f3afd 100644
--- a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx
+++ b/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx
@@ -3,31 +3,43 @@ import React from 'react';
// Mock the store hooks directly without importing the actual stores
jest.mock('@/stores/app/core-store', () => ({
- useCoreStore: jest.fn(() => ({ activeUnit: null })),
+ useCoreStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ activeUnit: null }) : { activeUnit: null }),
}));
jest.mock('@/stores/app/location-store', () => ({
- useLocationStore: jest.fn(() => ({
+ useLocationStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
isMapLocked: false,
setMapLocked: jest.fn()
- })),
+ }) : {
+ isMapLocked: false,
+ setMapLocked: jest.fn()
+ }),
}));
jest.mock('@/stores/app/livekit-store', () => ({
- useLiveKitStore: jest.fn(() => ({
+ useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: jest.fn(),
+ currentRoomInfo: null,
+ isConnected: false,
+ isTalking: false,
+ }) : {
setIsBottomSheetVisible: jest.fn(),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
- })),
+ }),
}));
jest.mock('@/stores/app/audio-stream-store', () => ({
- useAudioStreamStore: jest.fn(() => ({
+ useAudioStreamStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: jest.fn(),
currentStream: null,
isPlaying: false,
- })),
+ }) : {
+ setIsBottomSheetVisible: jest.fn(),
+ currentStream: null,
+ isPlaying: false,
+ }),
}));
// Mock the AudioStreamBottomSheet component
@@ -62,23 +74,37 @@ describe('SidebarUnitCard', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: null,
+ }) : {
activeUnit: null,
});
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ isMapLocked: false,
+ setMapLocked: mockSetMapLocked,
+ }) : {
isMapLocked: false,
setMapLocked: mockSetMapLocked,
});
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
+ currentRoomInfo: null,
+ isConnected: false,
+ isTalking: false,
+ }) : {
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
currentRoomInfo: null,
isConnected: false,
isTalking: false,
});
- mockUseAudioStreamStore.mockReturnValue({
+ mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible,
+ currentStream: null,
+ isPlaying: false,
+ }) : {
setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible,
currentStream: null,
isPlaying: false,
diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx
index 64b8a064..a8afd4ca 100644
--- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx
@@ -57,14 +57,23 @@ describe('SidebarUnitCard', () => {
// Reset to default mocks
mockUseCoreStore.mockImplementation((selector) => selector({ activeUnit: null } as any));
- mockUseLocationStore.mockReturnValue({ isMapLocked: false, setMapLocked: jest.fn() });
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() });
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: jest.fn(),
+ currentRoomInfo: null,
+ isConnected: false,
+ isTalking: false,
+ }) : {
setIsBottomSheetVisible: jest.fn(),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
});
- mockUseAudioStreamStore.mockReturnValue({
+ mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: jest.fn(),
+ currentStream: null,
+ isPlaying: false,
+ }) : {
setIsBottomSheetVisible: jest.fn(),
currentStream: null,
isPlaying: false,
@@ -105,7 +114,10 @@ describe('SidebarUnitCard', () => {
it('handles map lock button press', () => {
const mockSetMapLocked = jest.fn();
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ isMapLocked: false,
+ setMapLocked: mockSetMapLocked
+ }) : {
isMapLocked: false,
setMapLocked: mockSetMapLocked
});
@@ -120,7 +132,11 @@ describe('SidebarUnitCard', () => {
it('handles audio stream button press', () => {
const mockSetAudioStreamBottomSheetVisible = jest.fn();
- mockUseAudioStreamStore.mockReturnValue({
+ mockUseAudioStreamStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible,
+ currentStream: null,
+ isPlaying: false,
+ }) : {
setIsBottomSheetVisible: mockSetAudioStreamBottomSheetVisible,
currentStream: null,
isPlaying: false,
@@ -136,7 +152,12 @@ describe('SidebarUnitCard', () => {
it('handles call button press', () => {
const mockSetIsBottomSheetVisible = jest.fn();
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
+ currentRoomInfo: null,
+ isConnected: false,
+ isTalking: false,
+ }) : {
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
currentRoomInfo: null,
isConnected: false,
@@ -153,7 +174,12 @@ describe('SidebarUnitCard', () => {
it('shows room status when connected', () => {
const mockRoomInfo = { Name: 'Emergency Call Room' };
- mockUseLiveKitStore.mockReturnValue({
+ mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ setIsBottomSheetVisible: jest.fn(),
+ currentRoomInfo: mockRoomInfo as any,
+ isConnected: true,
+ isTalking: false,
+ }) : {
setIsBottomSheetVisible: jest.fn(),
currentRoomInfo: mockRoomInfo as any,
isConnected: true,
@@ -167,7 +193,10 @@ describe('SidebarUnitCard', () => {
it('toggles map lock correctly when currently locked', () => {
const mockSetMapLocked = jest.fn();
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ isMapLocked: true,
+ setMapLocked: mockSetMapLocked
+ }) : {
isMapLocked: true,
setMapLocked: mockSetMapLocked
});
diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx
index dce54d1e..fdb70332 100644
--- a/src/components/sidebar/call-sidebar.tsx
+++ b/src/components/sidebar/call-sidebar.tsx
@@ -20,11 +20,9 @@ import { HStack } from '../ui/hstack';
export const SidebarCallCard = () => {
const { colorScheme } = useColorScheme();
- const { activeCall, activePriority, setActiveCall } = useCoreStore((state) => ({
- activeCall: state.activeCall,
- activePriority: state.activePriority,
- setActiveCall: state.setActiveCall,
- }));
+ const activeCall = useCoreStore((state) => state.activeCall);
+ const activePriority = useCoreStore((state) => state.activePriority);
+ const setActiveCall = useCoreStore((state) => state.setActiveCall);
const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false);
const { t } = useTranslation();
diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx
new file mode 100644
index 00000000..e787cd4a
--- /dev/null
+++ b/src/components/sidebar/sidebar-content.tsx
@@ -0,0 +1,97 @@
+import { useRouter } from 'expo-router';
+import { Settings } from 'lucide-react-native';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { ScrollView } from 'react-native';
+
+import { Button, ButtonText } from '@/components/ui/button';
+import { HStack } from '@/components/ui/hstack';
+import { VStack } from '@/components/ui/vstack';
+import { invertColor } from '@/lib/utils';
+import { useCoreStore } from '@/stores/app/core-store';
+import { useStatusBottomSheetStore } from '@/stores/status/store';
+
+import ZeroState from '../common/zero-state';
+import { StatusBottomSheet } from '../status/status-bottom-sheet';
+import { SidebarCallCard } from './call-sidebar';
+import { SidebarRolesCard } from './roles-sidebar';
+import { SidebarStatusCard } from './status-sidebar';
+import { SidebarUnitCard } from './unit-sidebar';
+
+const Sidebar = () => {
+ const activeStatuses = useCoreStore((state) => state.activeStatuses);
+ const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen);
+ const { t } = useTranslation();
+ const router = useRouter();
+
+ const isActiveStatusesEmpty = !activeStatuses?.Statuses || activeStatuses.Statuses.length === 0;
+
+ const handleNavigateToSettings = () => {
+ router.push('/settings');
+ };
+
+ return (
+
+
+ {/* First row - Two cards side by side */}
+
+
+
+
+
+
+
+
+ {/* Second row - Single card */}
+
+
+ {/* Third row - Status buttons or empty state */}
+ {isActiveStatusesEmpty ? (
+
+
+
+ ) : (
+
+ {activeStatuses?.Statuses.map((status) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/src/components/sidebar/sidebar.tsx b/src/components/sidebar/sidebar.tsx
index 38b2e9de..1fa2c4f8 100644
--- a/src/components/sidebar/sidebar.tsx
+++ b/src/components/sidebar/sidebar.tsx
@@ -1,97 +1,6 @@
-import { useRouter } from 'expo-router';
-import { Settings } from 'lucide-react-native';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import { ScrollView } from 'react-native';
-
-import { Button, ButtonText } from '@/components/ui/button';
-import { HStack } from '@/components/ui/hstack';
-import { VStack } from '@/components/ui/vstack';
-import { invertColor } from '@/lib/utils';
-import { useCoreStore } from '@/stores/app/core-store';
-import { useStatusBottomSheetStore } from '@/stores/status/store';
-
-import ZeroState from '../common/zero-state';
-import { StatusBottomSheet } from '../status/status-bottom-sheet';
-import { SidebarCallCard } from './call-sidebar';
-import { SidebarRolesCard } from './roles-sidebar';
-import { SidebarStatusCard } from './status-sidebar';
-import { SidebarUnitCard } from './unit-sidebar';
-
-const Sidebar = () => {
- const { activeStatuses } = useCoreStore();
- const { setIsOpen } = useStatusBottomSheetStore();
- const { t } = useTranslation();
- const router = useRouter();
-
- const isActiveStatusesEmpty = !activeStatuses?.Statuses || activeStatuses.Statuses.length === 0;
-
- const handleNavigateToSettings = () => {
- router.push('/settings');
- };
-
- return (
-
-
- {/* First row - Two cards side by side */}
-
-
-
-
-
-
-
-
- {/* Second row - Single card */}
-
-
- {/* Third row - Status buttons or empty state */}
- {isActiveStatusesEmpty ? (
-
-
-
- ) : (
-
- {activeStatuses?.Statuses.map((status) => (
-
- ))}
-
- )}
-
-
-
- );
-};
-
-export default Sidebar;
+// Re-export the sidebar content for native platforms.
+// On web, metro resolves to sidebar.web.tsx instead.
+// Using a separate sidebar-content.tsx file breaks the require cycle
+// that occurred when sidebar.web.tsx imported './sidebar' — which on web
+// resolved back to sidebar.web.tsx itself, causing infinite recursion / OOM.
+export { default } from './sidebar-content';
diff --git a/src/components/sidebar/sidebar.web.tsx b/src/components/sidebar/sidebar.web.tsx
index c4e70c41..d5de00eb 100644
--- a/src/components/sidebar/sidebar.web.tsx
+++ b/src/components/sidebar/sidebar.web.tsx
@@ -2,11 +2,13 @@ import React from 'react';
import { Box } from '@/components/ui/box';
-import Sidebar from './sidebar';
+// Import from sidebar-content directly to avoid require cycle.
+// On web, './sidebar' resolves to sidebar.web.tsx (this file), not sidebar.tsx.
+import Sidebar from './sidebar-content';
const WebSidebar = () => {
return (
-
+
{/* common sidebar contents for web and mobile */}
diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx
index 2fd6f61b..d1eebe6c 100644
--- a/src/components/sidebar/unit-sidebar.tsx
+++ b/src/components/sidebar/unit-sidebar.tsx
@@ -20,9 +20,15 @@ type ItemProps = {
export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => {
const activeUnit = useCoreStore((state) => state.activeUnit);
- const { setIsBottomSheetVisible, currentRoomInfo, isConnected, isTalking } = useLiveKitStore();
- const { isMapLocked, setMapLocked } = useLocationStore();
- const { setIsBottomSheetVisible: setAudioStreamBottomSheetVisible, currentStream, isPlaying } = useAudioStreamStore();
+ const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible);
+ const currentRoomInfo = useLiveKitStore((state) => state.currentRoomInfo);
+ const isConnected = useLiveKitStore((state) => state.isConnected);
+ const isTalking = useLiveKitStore((state) => state.isTalking);
+ const isMapLocked = useLocationStore((state) => state.isMapLocked);
+ const setMapLocked = useLocationStore((state) => state.setMapLocked);
+ const setAudioStreamBottomSheetVisible = useAudioStreamStore((state) => state.setIsBottomSheetVisible);
+ const currentStream = useAudioStreamStore((state) => state.currentStream);
+ const isPlaying = useAudioStreamStore((state) => state.isPlaying);
// Derive the display values from activeUnit when available, otherwise use defaults
const displayName = activeUnit?.Name ?? defaultUnitName;
diff --git a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx b/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx
index 7c5c953d..50f16d13 100644
--- a/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx
+++ b/src/components/status/__tests__/gps-coordinate-duplication-fix.test.tsx
@@ -62,7 +62,7 @@ describe('GPS Coordinate Duplication Fix', () => {
setActiveUnitWithFetch: jest.fn().mockResolvedValue({}),
};
- mockUseCoreStore.mockReturnValue(mockCoreStore);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore);
(mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore);
});
diff --git a/src/components/status/__tests__/location-update-validation.test.tsx b/src/components/status/__tests__/location-update-validation.test.tsx
index 426ae72e..1973ccdf 100644
--- a/src/components/status/__tests__/location-update-validation.test.tsx
+++ b/src/components/status/__tests__/location-update-validation.test.tsx
@@ -42,7 +42,7 @@ describe('Location Update Validation', () => {
setActiveUnitWithFetch: jest.fn().mockResolvedValue({}),
};
- mockUseCoreStore.mockReturnValue(mockCoreStore);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore);
(mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore);
});
diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx
index 76e8a68a..e7e88e57 100644
--- a/src/components/status/__tests__/status-bottom-sheet.test.tsx
+++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx
@@ -198,15 +198,7 @@ jest.mock('@/stores/app/core-store', () => {
});
jest.mock('@/stores/app/location-store', () => ({
- useLocationStore: jest.fn(() => ({
- latitude: 37.7749,
- longitude: -122.4194,
- heading: 0,
- accuracy: 10,
- speed: 0,
- altitude: 0,
- timestamp: Date.now(),
- })),
+ useLocationStore: jest.fn(),
}));
jest.mock('@/stores/roles/store', () => ({
@@ -225,6 +217,7 @@ import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/sto
import { useCoreStore } from '@/stores/app/core-store';
import { useRolesStore } from '@/stores/roles/store';
import { useToastStore } from '@/stores/toast/store';
+import { useLocationStore } from '@/stores/app/location-store';
import { StatusBottomSheet } from '../status-bottom-sheet';
@@ -285,6 +278,7 @@ const mockUseCoreStore = useCoreStore as unknown as jest.MockedFunction;
const mockGetState = (mockUseCoreStore as any).getState;
const mockUseRolesStore = useRolesStore as jest.MockedFunction;
const mockUseToastStore = useToastStore as jest.MockedFunction;
+const mockUseLocationStore = useLocationStore as jest.MockedFunction;
describe('StatusBottomSheet', () => {
const mockReset = jest.fn();
@@ -364,9 +358,28 @@ describe('StatusBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTranslation.mockReturnValue(mockTranslation as any);
- mockUseStatusBottomSheetStore.mockReturnValue(defaultBottomSheetStore);
- mockUseStatusesStore.mockReturnValue(defaultStatusesStore);
- mockUseToastStore.mockReturnValue({ showToast: mockShowToast });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultBottomSheetStore);
+ }
+ return defaultBottomSheetStore;
+ });
+
+ mockUseStatusesStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultStatusesStore);
+ }
+ return defaultStatusesStore;
+ });
+
+ mockUseToastStore.mockImplementation((selector: any) => {
+ const store = { showToast: mockShowToast };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
// Set up the core store mock with getState that returns the store state
mockGetState.mockReturnValue(defaultCoreStore as any);
@@ -377,7 +390,29 @@ describe('StatusBottomSheet', () => {
}
return defaultCoreStore;
});
- mockUseRolesStore.mockReturnValue(defaultRolesStore);
+
+ mockUseRolesStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(defaultRolesStore);
+ }
+ return defaultRolesStore;
+ });
+
+ mockUseLocationStore.mockImplementation((selector: any) => {
+ const store = {
+ latitude: 37.7749,
+ longitude: -122.4194,
+ heading: 0,
+ accuracy: 10,
+ speed: 0,
+ altitude: 0,
+ timestamp: Date.now(),
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
+ });
});
it('should be importable without error', () => {
@@ -398,10 +433,16 @@ describe('StatusBottomSheet', () => {
Note: 1, // Note optional - this gives us 2 steps
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -420,11 +461,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -452,11 +499,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -484,11 +537,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableStations: [mockStation],
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableStations: [mockStation],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -521,16 +580,22 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: null,
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: null,
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -564,16 +629,22 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: 'call-1',
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: 'call-1',
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -607,16 +678,22 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: 'call-1',
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: 'call-1',
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -637,12 +714,18 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [{ CallId: 'call-1', Number: 'C001', Name: 'Call', Address: '' }],
- availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }],
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [{ CallId: 'call-1', Number: 'C001', Name: 'Call', Address: '' }],
+ availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -659,11 +742,17 @@ describe('StatusBottomSheet', () => {
Note: 1, // Note optional
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -682,12 +771,18 @@ describe('StatusBottomSheet', () => {
Note: 1, // Note optional
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'add-note',
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -708,11 +803,17 @@ describe('StatusBottomSheet', () => {
Note: 1,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'add-note',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -731,11 +832,17 @@ describe('StatusBottomSheet', () => {
Note: 2, // Note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'add-note',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -754,12 +861,18 @@ describe('StatusBottomSheet', () => {
Note: 2, // Note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'add-note',
- note: '', // Empty note
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ note: '', // Empty note
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -785,12 +898,18 @@ describe('StatusBottomSheet', () => {
Note: 2, // Note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'add-note',
- note: 'Test note', // Note provided
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'add-note',
+ note: 'Test note', // Note provided
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -816,10 +935,16 @@ describe('StatusBottomSheet', () => {
Note: 0, // No note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -845,13 +970,19 @@ describe('StatusBottomSheet', () => {
{ CallId: 'call-1', Name: 'Test Call', Number: '123', Address: 'Test Address' },
];
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'select-destination',
- isLoading: true, // This should show loading instead of the call list
- availableCalls: mockAvailableCalls,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'select-destination',
+ isLoading: true, // This should show loading instead of the call list
+ availableCalls: mockAvailableCalls,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -879,17 +1010,23 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: null,
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: null,
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -923,17 +1060,23 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: 'call-1',
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: 'call-1',
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -967,17 +1110,23 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: 'call-1',
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: 'call-1',
- }));
-
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ }; return typeof selector === 'function' ? selector(store) : store; });
+
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -999,11 +1148,17 @@ describe('StatusBottomSheet', () => {
Note: 0, // No note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1032,12 +1187,18 @@ describe('StatusBottomSheet', () => {
Note: 0, // No note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedStation: mockStation,
- selectedDestinationType: 'station',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedStation: mockStation,
+ selectedDestinationType: 'station',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1071,10 +1232,10 @@ describe('StatusBottomSheet', () => {
...defaultCoreStore,
activeCallId: null,
} as any);
- (useCoreStore as any).mockImplementation(() => ({
+ (useCoreStore as any).mockImplementation((selector: any) => { const store = {
...defaultCoreStore,
activeCallId: null,
- }));
+ }; return typeof selector === 'function' ? selector(store) : store; });
const mockStore = {
...defaultBottomSheetStore,
@@ -1083,7 +1244,12 @@ describe('StatusBottomSheet', () => {
availableCalls: [mockCall],
};
- mockUseStatusBottomSheetStore.mockReturnValue(mockStore);
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(mockStore);
+ }
+ return mockStore;
+ });
render();
@@ -1096,10 +1262,16 @@ describe('StatusBottomSheet', () => {
expect(mockSetActiveCall).not.toHaveBeenCalled();
// Update mock store to reflect call selection
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...mockStore,
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...mockStore,
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
// Step 2: Navigate to next step
@@ -1110,11 +1282,17 @@ describe('StatusBottomSheet', () => {
expect(mockSetActiveCall).not.toHaveBeenCalled();
// Re-render to show the final step (submit)
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...mockStore,
- selectedCall: mockCall,
- selectedDestinationType: 'call',
- currentStep: 'select-destination',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...mockStore,
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ currentStep: 'select-destination',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1137,10 +1315,16 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1156,11 +1340,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1185,13 +1375,19 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1215,13 +1411,19 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableStations: [mockStation],
- selectedStation: mockStation,
- selectedDestinationType: 'station',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableStations: [mockStation],
+ selectedStation: mockStation,
+ selectedDestinationType: 'station',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1245,13 +1447,19 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1287,14 +1495,20 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
- availableStations: [mockStation],
- selectedStation: mockStation,
- selectedDestinationType: 'station',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ availableStations: [mockStation],
+ selectedStation: mockStation,
+ selectedDestinationType: 'station',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1330,14 +1544,20 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [mockCall],
- availableStations: [mockStation],
- selectedCall: mockCall,
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [mockCall],
+ availableStations: [mockStation],
+ selectedCall: mockCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1371,11 +1591,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: manyCalls,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: manyCalls,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1409,11 +1635,17 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableStations: manyStations,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableStations: manyStations,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1466,14 +1698,20 @@ describe('StatusBottomSheet', () => {
return coreStoreWithActiveCall;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [otherCall, activeCall], // Active call is in the list
- isLoading: false,
- selectedCall: null, // No call initially selected
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [otherCall, activeCall], // Active call is in the list
+ isLoading: false,
+ selectedCall: null, // No call initially selected
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1513,15 +1751,21 @@ describe('StatusBottomSheet', () => {
return coreStoreWithActiveCall;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [activeCall],
- availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }],
- isLoading: false,
- selectedCall: null,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [activeCall],
+ availableStations: [{ GroupId: 'station-1', Name: 'Station 1', Address: '', GroupType: 'Station' }],
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1554,14 +1798,20 @@ describe('StatusBottomSheet', () => {
activeCallId: 'active-call-123',
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [activeCall], // Active call is in the list but not relevant for this status
- isLoading: false,
- selectedCall: null,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [activeCall], // Active call is in the list but not relevant for this status
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1592,14 +1842,20 @@ describe('StatusBottomSheet', () => {
activeCallId: 'different-active-call-999',
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [availableCall], // Active call is NOT in this list
- isLoading: false,
- selectedCall: null,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [availableCall], // Active call is NOT in this list
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1630,14 +1886,20 @@ describe('StatusBottomSheet', () => {
activeCallId: null,
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [availableCall],
- isLoading: false,
- selectedCall: null,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [availableCall],
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1675,14 +1937,20 @@ describe('StatusBottomSheet', () => {
activeCallId: 'active-call-123',
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [activeCall, alreadySelectedCall],
- isLoading: false,
- selectedCall: alreadySelectedCall, // Already has a selected call
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [activeCall, alreadySelectedCall],
+ isLoading: false,
+ selectedCall: alreadySelectedCall, // Already has a selected call
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1713,14 +1981,20 @@ describe('StatusBottomSheet', () => {
activeCallId: 'active-call-123',
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [activeCall],
- isLoading: false,
- selectedCall: null,
- selectedDestinationType: 'station', // Not 'none', so should not change selection
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [activeCall],
+ isLoading: false,
+ selectedCall: null,
+ selectedDestinationType: 'station', // Not 'none', so should not change selection
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1751,14 +2025,20 @@ describe('StatusBottomSheet', () => {
activeCallId: 'active-call-123',
} as any);
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- availableCalls: [activeCall],
- isLoading: true, // Still loading
- selectedCall: null,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ availableCalls: [activeCall],
+ isLoading: true, // Still loading
+ selectedCall: null,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1807,7 +2087,12 @@ describe('StatusBottomSheet', () => {
selectedDestinationType: 'none',
};
- mockUseStatusBottomSheetStore.mockReturnValue(currentStore);
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(currentStore);
+ }
+ return currentStore;
+ });
const { rerender } = render();
@@ -1828,7 +2113,12 @@ describe('StatusBottomSheet', () => {
selectedDestinationType: 'call' as const,
};
- mockUseStatusBottomSheetStore.mockReturnValue(updatedStore);
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(updatedStore);
+ }
+ return updatedStore;
+ });
rerender();
@@ -1884,7 +2174,12 @@ describe('StatusBottomSheet', () => {
selectedDestinationType: 'none',
};
- mockUseStatusBottomSheetStore.mockReturnValue(loadingStore);
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ if (selector) {
+ return selector(loadingStore);
+ }
+ return loadingStore;
+ });
const { rerender } = render();
@@ -1900,12 +2195,18 @@ describe('StatusBottomSheet', () => {
// New tests for status selection step
it('should render status selection step when no status is pre-selected', () => {
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null, // No status pre-selected
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null, // No status pre-selected
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1919,11 +2220,17 @@ describe('StatusBottomSheet', () => {
});
it('should display checkmarks instead of radio buttons for status selection', () => {
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1946,10 +2253,16 @@ describe('StatusBottomSheet', () => {
Note: 1, // Note optional
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1962,12 +2275,18 @@ describe('StatusBottomSheet', () => {
it('should handle status selection', () => {
const mockSetSelectedStatus = jest.fn();
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
- setSelectedStatus: mockSetSelectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ setSelectedStatus: mockSetSelectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -1986,11 +2305,17 @@ describe('StatusBottomSheet', () => {
});
it('should show status details in status selection', () => {
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2008,13 +2333,19 @@ describe('StatusBottomSheet', () => {
it('should disable next button on status selection when no status is selected', () => {
const mockSetCurrentStep = jest.fn();
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
- cameFromStatusSelection: true,
- setCurrentStep: mockSetCurrentStep,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ cameFromStatusSelection: true,
+ setCurrentStep: mockSetCurrentStep,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2041,13 +2372,19 @@ describe('StatusBottomSheet', () => {
Detail: 1,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus,
- cameFromStatusSelection: true,
- setCurrentStep: mockSetCurrentStep,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus,
+ cameFromStatusSelection: true,
+ setCurrentStep: mockSetCurrentStep,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2072,11 +2409,17 @@ describe('StatusBottomSheet', () => {
Detail: 2, // Has destination step
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2102,11 +2445,17 @@ describe('StatusBottomSheet', () => {
const mockHandleSubmit = jest.fn();
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2130,11 +2479,17 @@ describe('StatusBottomSheet', () => {
Detail: 0, // No destination step
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2159,11 +2514,17 @@ describe('StatusBottomSheet', () => {
Detail: 2,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-destination',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-destination',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2172,11 +2533,17 @@ describe('StatusBottomSheet', () => {
fireEvent.press(nextButton);
// Now in note step
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2200,11 +2567,17 @@ describe('StatusBottomSheet', () => {
Detail: 0, // No destination step
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2230,36 +2603,54 @@ describe('StatusBottomSheet', () => {
};
// Step 1: Status selection (no status selected yet)
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null, // No status selected yet, so we see status selection
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null, // No status selected yet, so we see status selection
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
const { rerender } = render();
expect(screen.getByText('Step 1 of 3')).toBeTruthy();
// Step 2: After selecting status, now on destination step (from new flow)
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-destination',
- selectedStatus: selectedStatusWithAll,
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-destination',
+ selectedStatus: selectedStatusWithAll,
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
rerender();
expect(screen.getByText('Step 2 of 3')).toBeTruthy();
// Step 3: Note step (from new flow)
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus: selectedStatusWithAll,
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus: selectedStatusWithAll,
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
rerender();
@@ -2301,24 +2692,36 @@ describe('StatusBottomSheet', () => {
});
// Status selection step
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
const { rerender } = render();
expect(screen.getByText('Step 1 of 2')).toBeTruthy();
// Note step (skipping destination)
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus: selectedStatusNoDestination,
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus: selectedStatusNoDestination,
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
rerender();
@@ -2343,11 +2746,17 @@ describe('StatusBottomSheet', () => {
return coreStoreNoStatuses;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2370,11 +2779,17 @@ describe('StatusBottomSheet', () => {
return coreStoreNullStatuses;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2406,14 +2821,20 @@ describe('StatusBottomSheet', () => {
GroupType: 'Station',
}));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus: committedStatus,
- currentStep: 'select-destination',
- availableCalls: mockCalls,
- availableStations: mockStations,
- isLoading: false,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus: committedStatus,
+ currentStep: 'select-destination',
+ availableCalls: mockCalls,
+ availableStations: mockStations,
+ isLoading: false,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2465,14 +2886,20 @@ describe('StatusBottomSheet', () => {
GroupType: 'Station',
}));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus: committedStatus,
- currentStep: 'select-destination',
- availableCalls: manyCalls,
- availableStations: manyStations,
- isLoading: false,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus: committedStatus,
+ currentStep: 'select-destination',
+ availableCalls: manyCalls,
+ availableStations: manyStations,
+ isLoading: false,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2521,12 +2948,18 @@ describe('StatusBottomSheet', () => {
return coreStoreWithManyStatuses;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
- cameFromStatusSelection: true,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ cameFromStatusSelection: true,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2570,13 +3003,19 @@ describe('StatusBottomSheet', () => {
GroupType: 'Station',
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- selectedStatus,
- currentStep: 'select-destination',
- availableCalls: [mockCall],
- availableStations: [mockStation],
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ currentStep: 'select-destination',
+ availableCalls: [mockCall],
+ availableStations: [mockStation],
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2612,13 +3051,19 @@ describe('StatusBottomSheet', () => {
Address: '123 Main St',
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedCall,
- selectedDestinationType: 'call',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedCall,
+ selectedDestinationType: 'call',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2641,18 +3086,24 @@ describe('StatusBottomSheet', () => {
// Mock a slow save operation
const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusesStore.mockReturnValue({
+ mockUseStatusesStore.mockImplementation((selector: any) => { const store = {
...defaultStatusesStore,
saveUnitStatus: slowSaveUnitStatus,
- });
+ }; return typeof selector === 'function' ? selector(store) : store; });
render();
@@ -2677,12 +3128,18 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2708,18 +3165,24 @@ describe('StatusBottomSheet', () => {
const errorSaveUnitStatus = jest.fn().mockRejectedValue(new Error('Network error'));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusesStore.mockReturnValue({
+ mockUseStatusesStore.mockImplementation((selector: any) => { const store = {
...defaultStatusesStore,
saveUnitStatus: errorSaveUnitStatus,
- });
+ }; return typeof selector === 'function' ? selector(store) : store; });
render();
@@ -2746,12 +3209,18 @@ describe('StatusBottomSheet', () => {
Note: 0,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2781,18 +3250,24 @@ describe('StatusBottomSheet', () => {
// Create a mock that resolves after a short delay to simulate API call
const fastSaveUnitStatus = jest.fn().mockImplementation(() => Promise.resolve());
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusesStore.mockReturnValue({
+ mockUseStatusesStore.mockImplementation((selector: any) => { const store = {
...defaultStatusesStore,
saveUnitStatus: fastSaveUnitStatus,
- });
+ }; return typeof selector === 'function' ? selector(store) : store; });
render();
@@ -2826,19 +3301,25 @@ describe('StatusBottomSheet', () => {
const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
- note: 'Test note',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ note: 'Test note',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
- mockUseStatusesStore.mockReturnValue({
+ mockUseStatusesStore.mockImplementation((selector: any) => { const store = {
...defaultStatusesStore,
saveUnitStatus: slowSaveUnitStatus,
- });
+ }; return typeof selector === 'function' ? selector(store) : store; });
render();
@@ -2863,12 +3344,18 @@ describe('StatusBottomSheet', () => {
Note: 1,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2909,14 +3396,20 @@ describe('StatusBottomSheet', () => {
return coreStoreWithActiveCall;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'call', // Set to call but no selectedCall yet
- selectedCall: null, // This is the issue scenario
- availableCalls,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'call', // Set to call but no selectedCall yet
+ selectedCall: null, // This is the issue scenario
+ availableCalls,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2948,14 +3441,20 @@ describe('StatusBottomSheet', () => {
return coreStoreWithActiveCall;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus,
- selectedDestinationType: 'call', // Set to call but no selectedCall yet
- selectedCall: null, // This is the issue scenario
- availableCalls: [], // No calls available yet
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus,
+ selectedDestinationType: 'call', // Set to call but no selectedCall yet
+ selectedCall: null, // This is the issue scenario
+ availableCalls: [], // No calls available yet
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -2995,11 +3494,17 @@ describe('StatusBottomSheet', () => {
return coreStoreWithBColor;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -3022,12 +3527,18 @@ describe('StatusBottomSheet', () => {
Note: 1,
};
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'add-note',
- selectedStatus: statusWithBColor,
- selectedDestinationType: 'none',
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'add-note',
+ selectedStatus: statusWithBColor,
+ selectedDestinationType: 'none',
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -3066,11 +3577,17 @@ describe('StatusBottomSheet', () => {
return coreStoreNoBColor;
});
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-status',
- selectedStatus: null,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-status',
+ selectedStatus: null,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
@@ -3105,14 +3622,20 @@ describe('StatusBottomSheet', () => {
GroupType: 'Fire Station',
}));
- mockUseStatusBottomSheetStore.mockReturnValue({
- ...defaultBottomSheetStore,
- isOpen: true,
- currentStep: 'select-destination',
- selectedStatus: committedStatus,
- availableCalls: manyCalls,
- availableStations: manyStations,
- isLoading: false,
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => {
+ const store = {
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ currentStep: 'select-destination',
+ selectedStatus: committedStatus,
+ availableCalls: manyCalls,
+ availableStations: manyStations,
+ isLoading: false,
+ };
+ if (selector) {
+ return selector(store);
+ }
+ return store;
});
render();
diff --git a/src/components/status/__tests__/status-gps-debug.test.tsx b/src/components/status/__tests__/status-gps-debug.test.tsx
index b4c535f8..d266fc37 100644
--- a/src/components/status/__tests__/status-gps-debug.test.tsx
+++ b/src/components/status/__tests__/status-gps-debug.test.tsx
@@ -64,7 +64,13 @@ const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction {
it('should render Submit button with minimal setup', () => {
// Mock all required stores
- mockUseCoreStore.mockReturnValue({
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ activeUnit: {
+ UnitId: 'unit1',
+ Name: 'Unit 1',
+ Type: 'Engine',
+ },
+ }) : {
activeUnit: {
UnitId: 'unit1',
Name: 'Unit 1',
@@ -72,11 +78,21 @@ describe('Status GPS Debug Test', () => {
},
});
- mockUseStatusesStore.mockReturnValue({
+ mockUseStatusesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ saveUnitStatus: mockSaveUnitStatus,
+ }) : {
saveUnitStatus: mockSaveUnitStatus,
});
- mockUseLocationStore.mockReturnValue({
+ mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ latitude: 40.7128,
+ longitude: -74.0060,
+ accuracy: 10,
+ altitude: 50,
+ speed: 0,
+ heading: 180,
+ timestamp: '2025-08-06T17:30:00.000Z',
+ }) : {
latitude: 40.7128,
longitude: -74.0060,
accuracy: 10,
@@ -86,7 +102,9 @@ describe('Status GPS Debug Test', () => {
timestamp: '2025-08-06T17:30:00.000Z',
});
- mockUseRolesStore.mockReturnValue({
+ mockUseRolesStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ unitRoleAssignments: [],
+ }) : {
unitRoleAssignments: [],
});
@@ -119,7 +137,11 @@ describe('Status GPS Debug Test', () => {
Note: 0, // No note required
};
- mockUseStatusBottomSheetStore.mockReturnValue({
+ mockUseStatusBottomSheetStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ ...defaultBottomSheetStore,
+ isOpen: true,
+ selectedStatus,
+ }) : {
...defaultBottomSheetStore,
isOpen: true,
selectedStatus,
diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx
index c8e007d2..c783b1b9 100644
--- a/src/components/status/__tests__/status-gps-integration-working.test.tsx
+++ b/src/components/status/__tests__/status-gps-integration-working.test.tsx
@@ -154,7 +154,7 @@ describe('Status GPS Integration', () => {
// Also mock getState for the location store logic
(mockUseLocationStore as any).getState = jest.fn().mockReturnValue(mockLocationStore);
- mockUseCoreStore.mockReturnValue(mockCoreStore);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore);
// Also mock getState for the status store logic
(mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore);
mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id');
diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx
index f2636e88..21b2774e 100644
--- a/src/components/status/__tests__/status-gps-integration.test.tsx
+++ b/src/components/status/__tests__/status-gps-integration.test.tsx
@@ -63,7 +63,7 @@ describe('Status GPS Integration', () => {
// Also mock getState method
(mockUseLocationStore as any).getState = jest.fn().mockReturnValue(mockLocationStore);
- mockUseCoreStore.mockReturnValue(mockCoreStore);
+ mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCoreStore) : mockCoreStore);
// Also mock getState for the status store logic
(mockUseCoreStore as any).getState = jest.fn().mockReturnValue(mockCoreStore);
mockOfflineEventManager.queueUnitStatusEvent.mockReturnValue('queued-event-id');
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index c2475587..490ae082 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -4,6 +4,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, TouchableOpacity } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+import { useShallow } from 'zustand/react/shallow';
import { invertColor } from '@/lib/utils';
import { type CustomStatusResultData } from '@/models/v4/customStatuses/customStatusResultData';
@@ -30,40 +31,48 @@ export const StatusBottomSheet = () => {
const [selectedTab, setSelectedTab] = React.useState<'calls' | 'stations'>('calls');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const hasPreselectedRef = React.useRef(false);
- const { showToast } = useToastStore();
+ const showToast = useToastStore((state) => state.showToast);
// Initialize offline event manager on mount
React.useEffect(() => {
offlineEventManager.initialize();
}, []);
- const {
- isOpen,
- currentStep,
- selectedCall,
- selectedStation,
- selectedDestinationType,
- selectedStatus,
- cameFromStatusSelection,
- note,
- availableCalls,
- availableStations,
- isLoading,
- setIsOpen,
- setCurrentStep,
- setSelectedCall,
- setSelectedStation,
- setSelectedDestinationType,
- setSelectedStatus,
- setNote,
- fetchDestinationData,
- reset,
- } = useStatusBottomSheetStore();
-
- const { activeUnit, activeCallId, setActiveCall, activeStatuses } = useCoreStore();
- const { unitRoleAssignments } = useRolesStore();
- const { saveUnitStatus } = useStatusesStore();
- const { latitude, longitude, heading, accuracy, speed, altitude, timestamp } = useLocationStore();
+ // Use individual selectors to avoid whole-store subscriptions (React 19 compatibility)
+ const isOpen = useStatusBottomSheetStore((state) => state.isOpen);
+ const currentStep = useStatusBottomSheetStore((state) => state.currentStep);
+ const selectedCall = useStatusBottomSheetStore((state) => state.selectedCall);
+ const selectedStation = useStatusBottomSheetStore((state) => state.selectedStation);
+ const selectedDestinationType = useStatusBottomSheetStore((state) => state.selectedDestinationType);
+ const selectedStatus = useStatusBottomSheetStore((state) => state.selectedStatus);
+ const cameFromStatusSelection = useStatusBottomSheetStore((state) => state.cameFromStatusSelection);
+ const note = useStatusBottomSheetStore((state) => state.note);
+ const availableCalls = useStatusBottomSheetStore((state) => state.availableCalls);
+ const availableStations = useStatusBottomSheetStore((state) => state.availableStations);
+ const isLoading = useStatusBottomSheetStore((state) => state.isLoading);
+ const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen);
+ const setCurrentStep = useStatusBottomSheetStore((state) => state.setCurrentStep);
+ const setSelectedCall = useStatusBottomSheetStore((state) => state.setSelectedCall);
+ const setSelectedStation = useStatusBottomSheetStore((state) => state.setSelectedStation);
+ const setSelectedDestinationType = useStatusBottomSheetStore((state) => state.setSelectedDestinationType);
+ const setSelectedStatus = useStatusBottomSheetStore((state) => state.setSelectedStatus);
+ const setNote = useStatusBottomSheetStore((state) => state.setNote);
+ const fetchDestinationData = useStatusBottomSheetStore((state) => state.fetchDestinationData);
+ const reset = useStatusBottomSheetStore((state) => state.reset);
+
+ const activeUnit = useCoreStore((state) => state.activeUnit);
+ const activeCallId = useCoreStore((state) => state.activeCallId);
+ const setActiveCall = useCoreStore((state) => state.setActiveCall);
+ const activeStatuses = useCoreStore((state) => state.activeStatuses);
+ const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments);
+ const saveUnitStatus = useStatusesStore((state) => state.saveUnitStatus);
+ const latitude = useLocationStore((state) => state.latitude);
+ const longitude = useLocationStore((state) => state.longitude);
+ const heading = useLocationStore((state) => state.heading);
+ const accuracy = useLocationStore((state) => state.accuracy);
+ const speed = useLocationStore((state) => state.speed);
+ const altitude = useLocationStore((state) => state.altitude);
+ const timestamp = useLocationStore((state) => state.timestamp);
// Set default tab based on DetailType when status changes
React.useEffect(() => {
diff --git a/src/components/toast/__tests__/toast-container.test.tsx b/src/components/toast/__tests__/toast-container.test.tsx
index 47b1d2d2..2d8d8638 100644
--- a/src/components/toast/__tests__/toast-container.test.tsx
+++ b/src/components/toast/__tests__/toast-container.test.tsx
@@ -41,7 +41,7 @@ describe('ToastContainer', () => {
});
it('renders nothing when no toasts are present', () => {
- mockUseToastStore.mockReturnValue([]);
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: [] }) : { toasts: [] });
const { queryByTestId } = render();
@@ -55,7 +55,7 @@ describe('ToastContainer', () => {
{ id: '2', type: 'error' as const, message: 'Error message', title: 'Error Title' },
];
- mockUseToastStore.mockReturnValue(mockToasts);
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: mockToasts }) : { toasts: mockToasts });
const { getByTestId } = render();
@@ -68,7 +68,7 @@ describe('ToastContainer', () => {
{ id: '1', type: 'warning' as const, message: 'Warning message', title: 'Warning Title' },
];
- mockUseToastStore.mockReturnValue(mockToasts);
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ toasts: mockToasts }) : { toasts: mockToasts });
const { getByTestId } = render();
diff --git a/src/components/ui/accordion/index.tsx b/src/components/ui/accordion/index.tsx
index 51f629bc..58733be3 100644
--- a/src/components/ui/accordion/index.tsx
+++ b/src/components/ui/accordion/index.tsx
@@ -169,7 +169,8 @@ type IAccordionTitleTextProps = React.ComponentPropsWithoutRef, IAccordionProps>(({ className, variant = 'filled', size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ variant, size }), [variant, size]);
+ return ;
});
const AccordionItem = React.forwardRef, IAccordionItemProps>(({ className, ...props }, ref) => {
diff --git a/src/components/ui/alert-dialog/index.tsx b/src/components/ui/alert-dialog/index.tsx
index ad7b0f73..04748741 100644
--- a/src/components/ui/alert-dialog/index.tsx
+++ b/src/components/ui/alert-dialog/index.tsx
@@ -95,7 +95,8 @@ type IAlertDialogBodyProps = React.ComponentPropsWithoutRef & VariantProps & { className?: string };
const AlertDialog = React.forwardRef, IAlertDialogProps>(({ className, size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
const AlertDialogContent = React.forwardRef, IAlertDialogContentProps>(({ className, size, ...props }, ref) => {
diff --git a/src/components/ui/alert/index.tsx b/src/components/ui/alert/index.tsx
index 95b462e8..5c1ffb8a 100644
--- a/src/components/ui/alert/index.tsx
+++ b/src/components/ui/alert/index.tsx
@@ -168,7 +168,8 @@ cssInterop(IconWrapper, {
type IAlertProps = Omit, 'context'> & VariantProps;
const Alert = React.forwardRef, IAlertProps>(({ className, variant = 'solid', action = 'muted', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ variant, action }), [variant, action]);
+ return ;
});
type IAlertTextProps = React.ComponentPropsWithoutRef & VariantProps;
diff --git a/src/components/ui/avatar/index.tsx b/src/components/ui/avatar/index.tsx
index 56fbe1ec..eb94193f 100644
--- a/src/components/ui/avatar/index.tsx
+++ b/src/components/ui/avatar/index.tsx
@@ -76,7 +76,8 @@ const avatarImageStyle = tva({
type IAvatarProps = Omit, 'context'> & VariantProps;
export const Avatar = React.forwardRef, IAvatarProps>(({ className, size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
type IAvatarBadgeProps = React.ComponentPropsWithoutRef & VariantProps;
diff --git a/src/components/ui/badge/index.tsx b/src/components/ui/badge/index.tsx
index d6bf3063..61ea8542 100644
--- a/src/components/ui/badge/index.tsx
+++ b/src/components/ui/badge/index.tsx
@@ -144,16 +144,9 @@ cssInterop(PrimitiveIcon, {
type IBadgeProps = React.ComponentPropsWithoutRef & VariantProps;
const Badge = ({ children, action = 'info', variant = 'solid', size = 'md', className, ...props }: { className?: string } & IBadgeProps) => {
+ const contextValue = useMemo(() => ({ action, variant, size }), [action, variant, size]);
return (
-
+
{children}
);
diff --git a/src/components/ui/bottomsheet/index.tsx b/src/components/ui/bottomsheet/index.tsx
index 54e60e37..67ebdc76 100644
--- a/src/components/ui/bottomsheet/index.tsx
+++ b/src/components/ui/bottomsheet/index.tsx
@@ -64,18 +64,17 @@ export const BottomSheet = ({ snapToIndex = 1, onOpen, onClose, ...props }: { sn
onClose && onClose();
}, [onClose]);
- return (
- ,
- handleClose,
- handleOpen,
- }}
- >
- {props.children}
-
+ const contextValue = useMemo(
+ () => ({
+ visible,
+ bottomSheetRef: bottomSheetRef as React.RefObject,
+ handleClose,
+ handleOpen,
+ }),
+ [visible, handleClose, handleOpen]
);
+
+ return {props.children};
};
export const BottomSheetPortal = ({
diff --git a/src/components/ui/button/index.tsx b/src/components/ui/button/index.tsx
index 9927328e..f92876da 100644
--- a/src/components/ui/button/index.tsx
+++ b/src/components/ui/button/index.tsx
@@ -246,7 +246,8 @@ const buttonGroupStyle = tva({
type IButtonProps = Omit, 'context'> & VariantProps & { className?: string };
const Button = React.forwardRef, IButtonProps>(function Button({ className, variant = 'solid', size = 'md', action = 'primary', ...props }, ref) {
- return ;
+ const contextValue = React.useMemo(() => ({ variant, size, action }), [variant, size, action]);
+ return ;
});
type IButtonTextProps = React.ComponentPropsWithoutRef & VariantProps & { className?: string };
diff --git a/src/components/ui/checkbox/index.tsx b/src/components/ui/checkbox/index.tsx
index 58898904..0b806016 100644
--- a/src/components/ui/checkbox/index.tsx
+++ b/src/components/ui/checkbox/index.tsx
@@ -138,6 +138,7 @@ const CheckboxGroup = UICheckbox.Group;
type ICheckboxProps = React.ComponentPropsWithoutRef & VariantProps;
const Checkbox = React.forwardRef, ICheckboxProps>(({ className, size = 'md', ...props }, ref) => {
+ const contextValue = React.useMemo(() => ({ size }), [size]);
return (
, ICheckbox
size,
})}
{...props}
- context={{
- size,
- }}
+ context={contextValue}
ref={ref}
/>
);
diff --git a/src/components/ui/drawer/index.tsx b/src/components/ui/drawer/index.tsx
index 80f05064..adf2d2ef 100644
--- a/src/components/ui/drawer/index.tsx
+++ b/src/components/ui/drawer/index.tsx
@@ -148,31 +148,31 @@ type IDrawerFooterProps = React.ComponentProps & Variant
type IDrawerCloseButtonProps = React.ComponentProps & VariantProps & { className?: string };
const Drawer = React.forwardRef, IDrawerProps>(({ className, size = 'sm', anchor = 'left', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size, anchor }), [size, anchor]);
+ return ;
});
+const backdropInitial = { opacity: 0 };
+const backdropAnimate = { opacity: 0.5 };
+const backdropExit = { opacity: 0 };
+const backdropTransition = {
+ type: 'spring' as const,
+ damping: 18,
+ stiffness: 250,
+ opacity: {
+ type: 'timing' as const,
+ duration: 250,
+ },
+};
+
const DrawerBackdrop = React.forwardRef, IDrawerBackdropProps>(({ className, ...props }, ref) => {
return (
const isHorizontal = parentAnchor === 'left' || parentAnchor === 'right';
- const initialObj = isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight };
+ const initialObj = React.useMemo(
+ () => (isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }),
+ [isHorizontal, parentAnchor, drawerWidth, drawerHeight]
+ );
- const animateObj = isHorizontal ? { x: 0 } : { y: 0 };
+ const animateObj = React.useMemo(() => (isHorizontal ? { x: 0 } : { y: 0 }), [isHorizontal]);
- const exitObj = isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight };
+ const exitObj = React.useMemo(
+ () => (isHorizontal ? { x: parentAnchor === 'left' ? -drawerWidth : drawerWidth } : { y: parentAnchor === 'top' ? -drawerHeight : drawerHeight }),
+ [isHorizontal, parentAnchor, drawerWidth, drawerHeight]
+ );
const customClass = isHorizontal ? `top-0 ${parentAnchor === 'left' ? 'left-0' : 'right-0'}` : `left-0 ${parentAnchor === 'top' ? 'top-0' : 'bottom-0'}`;
- return (
- ({
+ type: 'timing' as const,
+ duration: 300,
+ }),
+ []
+ );
+
+ const contentClassName = React.useMemo(
+ () =>
+ drawerContentStyle({
parentVariants: {
size: parentSize,
anchor: parentAnchor,
},
class: `${className} ${customClass}`,
- })}
- pointerEvents="auto"
- />
+ }),
+ [parentSize, parentAnchor, className, customClass]
);
+
+ return ;
});
const DrawerHeader = React.forwardRef, IDrawerHeaderProps>(({ className, ...props }, ref) => {
diff --git a/src/components/ui/fab/index.tsx b/src/components/ui/fab/index.tsx
index ee9648c2..71ecfd5a 100644
--- a/src/components/ui/fab/index.tsx
+++ b/src/components/ui/fab/index.tsx
@@ -114,7 +114,8 @@ const fabIconStyle = tva({
type IFabProps = Omit, 'context'> & VariantProps;
const Fab = React.forwardRef, IFabProps>(({ size = 'md', placement = 'bottom right', className, ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
type IFabLabelProps = React.ComponentPropsWithoutRef & VariantProps;
diff --git a/src/components/ui/form-control/index.tsx b/src/components/ui/form-control/index.tsx
index c16b6d0c..e3021813 100644
--- a/src/components/ui/form-control/index.tsx
+++ b/src/components/ui/form-control/index.tsx
@@ -296,7 +296,8 @@ cssInterop(UIFormControl.Error.Icon, {
type IFormControlProps = React.ComponentProps & VariantProps;
const FormControl = React.forwardRef, IFormControlProps>(({ className, size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
type IFormControlErrorProps = React.ComponentProps & VariantProps;
diff --git a/src/components/ui/gluestack-ui-provider/index.tsx b/src/components/ui/gluestack-ui-provider/index.tsx
index aa11fc60..0ddb6f62 100644
--- a/src/components/ui/gluestack-ui-provider/index.tsx
+++ b/src/components/ui/gluestack-ui-provider/index.tsx
@@ -1,26 +1,16 @@
import { OverlayProvider } from '@gluestack-ui/overlay';
import { ToastProvider } from '@gluestack-ui/toast';
-import { colorScheme as colorSchemeNW } from 'nativewind';
import React from 'react';
-import { type ColorSchemeName, useColorScheme, View, type ViewProps } from 'react-native';
+import { useColorScheme, View, type ViewProps } from 'react-native';
import { config } from './config';
-type ModeType = 'light' | 'dark' | 'system';
-
-const getColorSchemeName = (colorScheme: ColorSchemeName, mode: ModeType): 'light' | 'dark' => {
- if (mode === 'system') {
- return colorScheme ?? 'light';
- }
- return mode;
-};
-
export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: 'light' | 'dark' | 'system'; children?: React.ReactNode; style?: ViewProps['style'] }) {
- const colorScheme = useColorScheme();
-
- const colorSchemeName = getColorSchemeName(colorScheme, mode);
-
- colorSchemeNW.set(mode);
+ // Use react-native's useColorScheme which reads the OS preference.
+ // With darkMode: 'media', nativewind handles dark styling via CSS media queries,
+ // so we only need the OS value to pick the right gluestack CSS-variable config.
+ const osScheme = useColorScheme();
+ const colorSchemeName: 'light' | 'dark' = mode === 'system' ? (osScheme === 'dark' ? 'dark' : 'light') : mode === 'dark' ? 'dark' : 'light';
return (
diff --git a/src/components/ui/gluestack-ui-provider/index.web.tsx b/src/components/ui/gluestack-ui-provider/index.web.tsx
index 5a375107..ec319aae 100644
--- a/src/components/ui/gluestack-ui-provider/index.web.tsx
+++ b/src/components/ui/gluestack-ui-provider/index.web.tsx
@@ -18,17 +18,22 @@ const createStyle = (styleTagId: string) => {
export const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: 'light' | 'dark' | 'system'; children?: React.ReactNode }) {
- let cssVariablesWithMode = ``;
- Object.keys(config).forEach((configKey) => {
- cssVariablesWithMode += configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
- const cssVariables = Object.keys(config[configKey as keyof typeof config]).reduce((acc: string, curr: string) => {
- acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
- return acc;
- }, '');
- cssVariablesWithMode += `${cssVariables} \n}`;
- });
+ const cssVariablesWithMode = React.useMemo(() => {
+ let css = ``;
+ Object.keys(config).forEach((configKey) => {
+ css += configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
+ const cssVariables = Object.keys(config[configKey as keyof typeof config]).reduce((acc: string, curr: string) => {
+ acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
+ return acc;
+ }, '');
+ css += `${cssVariables} \n}`;
+ });
+ return css;
+ }, []);
- setFlushStyles(cssVariablesWithMode);
+ React.useEffect(() => {
+ setFlushStyles(cssVariablesWithMode);
+ }, [cssVariablesWithMode]);
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
script(e.matches ? 'dark' : 'light');
diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx
index 522df059..94d3ec63 100644
--- a/src/components/ui/input/index.tsx
+++ b/src/components/ui/input/index.tsx
@@ -100,7 +100,8 @@ const inputFieldStyle = tva({
type IInputProps = React.ComponentProps & VariantProps & { className?: string };
const Input = React.forwardRef, IInputProps>(function Input({ className, variant = 'outline', size = 'md', ...props }, ref) {
- return ;
+ const contextValue = React.useMemo(() => ({ variant, size }), [variant, size]);
+ return ;
});
type IInputIconProps = React.ComponentProps &
diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx
index 20a3f591..25e790d4 100644
--- a/src/components/ui/modal/index.tsx
+++ b/src/components/ui/modal/index.tsx
@@ -95,9 +95,10 @@ type IModalFooterProps = React.ComponentProps & VariantPr
type IModalCloseButtonProps = React.ComponentProps & VariantProps & { className?: string };
-const Modal = React.forwardRef, IModalProps>(({ className, size = 'md', ...props }, ref) => (
-
-));
+const Modal = React.forwardRef, IModalProps>(({ className, size = 'md', ...props }, ref) => {
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
+});
const ModalBackdrop = React.forwardRef, IModalBackdropProps>(({ className, ...props }, ref) => {
return (
diff --git a/src/components/ui/popover/index.tsx b/src/components/ui/popover/index.tsx
index 1998420d..92289d8e 100644
--- a/src/components/ui/popover/index.tsx
+++ b/src/components/ui/popover/index.tsx
@@ -116,7 +116,8 @@ type IPopoverBackdropProps = React.ComponentProps & V
type IPopoverCloseButtonProps = React.ComponentProps & VariantProps & { className?: string };
const Popover = React.forwardRef, IPopoverProps>(function Popover({ className, size = 'md', placement = 'bottom', ...props }, ref) {
- return ;
+ const contextValue = React.useMemo(() => ({ size, placement }), [size, placement]);
+ return ;
});
const PopoverContent = React.forwardRef, IPopoverContentProps>(function PopoverContent({ className, size, ...props }, ref) {
diff --git a/src/components/ui/progress/index.tsx b/src/components/ui/progress/index.tsx
index 37e2144a..4461adfa 100644
--- a/src/components/ui/progress/index.tsx
+++ b/src/components/ui/progress/index.tsx
@@ -46,7 +46,8 @@ type IProgressProps = VariantProps & React.ComponentProps<
type IProgressFilledTrackProps = VariantProps & React.ComponentProps;
export const Progress = React.forwardRef, IProgressProps>(({ className, size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
export const ProgressFilledTrack = React.forwardRef, IProgressFilledTrackProps>(({ className, ...props }, ref) => {
diff --git a/src/components/ui/radio/index.tsx b/src/components/ui/radio/index.tsx
index 82c8381a..de187ad8 100644
--- a/src/components/ui/radio/index.tsx
+++ b/src/components/ui/radio/index.tsx
@@ -90,7 +90,8 @@ const radioLabelStyle = tva({
type IRadioProps = Omit, 'context'> & VariantProps;
const Radio = React.forwardRef, IRadioProps>(function Radio({ className, size = 'md', ...props }, ref) {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
type IRadioGroupProps = React.ComponentProps & VariantProps;
diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx
index 57729ffd..6e55f8bf 100644
--- a/src/components/ui/select/index.tsx
+++ b/src/components/ui/select/index.tsx
@@ -142,6 +142,7 @@ const Select = React.forwardRef, ISelectProp
type ISelectTriggerProps = VariantProps & React.ComponentProps & { className?: string };
const SelectTrigger = React.forwardRef, ISelectTriggerProps>(function SelectTrigger({ className, size = 'md', variant = 'outline', ...props }, ref) {
+ const contextValue = React.useMemo(() => ({ size, variant }), [size, variant]);
return (
);
diff --git a/src/components/ui/shared-tabs.tsx b/src/components/ui/shared-tabs.tsx
index 888b8b1b..d16bb25f 100644
--- a/src/components/ui/shared-tabs.tsx
+++ b/src/components/ui/shared-tabs.tsx
@@ -55,7 +55,8 @@ export const SharedTabs: React.FC = ({
}) => {
const { t } = useTranslation();
const [localActiveIndex, setLocalActiveIndex] = useState(initialIndex);
- const { activeIndex, setActiveIndex } = useTabStore();
+ const activeIndex = useTabStore((s) => s.activeIndex);
+ const setActiveIndex = useTabStore((s) => s.setActiveIndex);
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const { colorScheme } = useColorScheme();
diff --git a/src/components/ui/skeleton/index.tsx b/src/components/ui/skeleton/index.tsx
index 58040a2f..98196a4e 100644
--- a/src/components/ui/skeleton/index.tsx
+++ b/src/components/ui/skeleton/index.tsx
@@ -1,5 +1,5 @@
import type { VariantProps } from '@gluestack-ui/nativewind-utils';
-import React, { forwardRef } from 'react';
+import React, { forwardRef, useEffect, useRef } from 'react';
import { Animated, Easing, Platform, View } from 'react-native';
import { skeletonStyle, skeletonTextStyle } from './styles';
@@ -18,34 +18,66 @@ type ISkeletonTextProps = React.ComponentProps &
};
const Skeleton = forwardRef, ISkeletonProps>(({ className, variant, children, startColor = 'bg-background-200', isLoaded = false, speed = 2, ...props }, ref) => {
- const pulseAnim = new Animated.Value(1);
+ const isWeb = Platform.OS === 'web';
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+ const animRef = useRef(null);
const customTimingFunction = Easing.bezier(0.4, 0, 0.6, 1);
const fadeDuration = 0.6;
- const animationDuration = (fadeDuration * 10000) / speed; // Convert seconds to milliseconds
+ const animationDuration = (fadeDuration * 10000) / speed;
- const pulse = Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1, // Start with opacity 1
- duration: animationDuration / 2, // Third of the animation duration
- easing: customTimingFunction,
- useNativeDriver: Platform.OS !== 'web',
- }),
- Animated.timing(pulseAnim, {
- toValue: 0.75,
- duration: animationDuration / 2, // Third of the animation duration
- easing: customTimingFunction,
- useNativeDriver: Platform.OS !== 'web',
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: animationDuration / 2, // Third of the animation duration
- easing: customTimingFunction,
- useNativeDriver: Platform.OS !== 'web',
- }),
- ]);
+ useEffect(() => {
+ // On web, use CSS animation instead to avoid Animated.loop JS driver overhead
+ if (isWeb) return;
+
+ if (!isLoaded) {
+ const pulse = Animated.sequence([
+ Animated.timing(pulseAnim, {
+ toValue: 1,
+ duration: animationDuration / 2,
+ easing: customTimingFunction,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 0.75,
+ duration: animationDuration / 2,
+ easing: customTimingFunction,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 1,
+ duration: animationDuration / 2,
+ easing: customTimingFunction,
+ useNativeDriver: true,
+ }),
+ ]);
+ animRef.current = Animated.loop(pulse);
+ animRef.current.start();
+ } else {
+ animRef.current?.stop();
+ animRef.current = null;
+ }
+
+ return () => {
+ animRef.current?.stop();
+ animRef.current = null;
+ };
+ }, [isLoaded, isWeb, animationDuration, pulseAnim, customTimingFunction]);
if (!isLoaded) {
- Animated.loop(pulse).start();
+ // On web, use a CSS keyframe animation to avoid JS-driven Animated.loop
+ if (isWeb) {
+ return (
+
+ );
+ }
return (
, ISkeletonProps>(({ cl
/>
);
} else {
- Animated.loop(pulse).stop();
-
return children;
}
});
diff --git a/src/components/ui/slider/index.tsx b/src/components/ui/slider/index.tsx
index 97c432f3..c2bc592e 100644
--- a/src/components/ui/slider/index.tsx
+++ b/src/components/ui/slider/index.tsx
@@ -159,6 +159,7 @@ const sliderFilledTrackStyle = tva({
type ISliderProps = React.ComponentProps & VariantProps;
export const Slider = React.forwardRef, ISliderProps>(({ className, size = 'md', orientation = 'horizontal', isReversed = false, ...props }, ref) => {
+ const contextValue = React.useMemo(() => ({ size, orientation, isReversed }), [size, orientation, isReversed]);
return (
, ISlide
isReversed,
class: className,
})}
- context={{ size, orientation, isReversed }}
+ context={contextValue}
/>
);
});
diff --git a/src/components/ui/textarea/index.tsx b/src/components/ui/textarea/index.tsx
index dfe6e9a2..710598df 100644
--- a/src/components/ui/textarea/index.tsx
+++ b/src/components/ui/textarea/index.tsx
@@ -56,7 +56,8 @@ const textareaInputStyle = tva({
type ITextareaProps = React.ComponentProps & VariantProps;
const Textarea = React.forwardRef, ITextareaProps>(({ className, variant = 'default', size = 'md', ...props }, ref) => {
- return ;
+ const contextValue = React.useMemo(() => ({ size }), [size]);
+ return ;
});
type ITextareaInputProps = React.ComponentProps & VariantProps;
diff --git a/src/components/ui/toast/index.tsx b/src/components/ui/toast/index.tsx
index 46b991ca..b3086fe3 100644
--- a/src/components/ui/toast/index.tsx
+++ b/src/components/ui/toast/index.tsx
@@ -149,7 +149,8 @@ type IToastProps = React.ComponentProps & {
} & VariantProps;
const Toast = React.forwardRef, IToastProps>(function Toast({ className, variant = 'solid', action = 'muted', ...props }, ref) {
- return ;
+ const contextValue = React.useMemo(() => ({ variant, action }), [variant, action]);
+ return ;
});
type IToastTitleProps = React.ComponentProps & {
diff --git a/src/features/livekit-call/components/LiveKitCallModal.tsx b/src/features/livekit-call/components/LiveKitCallModal.tsx
index 4ab492ad..02fa29f4 100644
--- a/src/features/livekit-call/components/LiveKitCallModal.tsx
+++ b/src/features/livekit-call/components/LiveKitCallModal.tsx
@@ -25,7 +25,14 @@ const LiveKitCallModal: React.FC = ({
onClose,
participantIdentity = `user-${Math.random().toString(36).substring(7)}`, // Default unique enough for example
}) => {
- const { availableRooms, selectedRoomForJoining, currentRoomId, isConnecting, isConnected, error, localParticipant, actions } = useLiveKitCallStore();
+ const availableRooms = useLiveKitCallStore((s) => s.availableRooms);
+ const selectedRoomForJoining = useLiveKitCallStore((s) => s.selectedRoomForJoining);
+ const currentRoomId = useLiveKitCallStore((s) => s.currentRoomId);
+ const isConnecting = useLiveKitCallStore((s) => s.isConnecting);
+ const isConnected = useLiveKitCallStore((s) => s.isConnected);
+ const error = useLiveKitCallStore((s) => s.error);
+ const localParticipant = useLiveKitCallStore((s) => s.localParticipant);
+ const actions = useLiveKitCallStore((s) => s.actions);
const [isMicrophoneEnabled, setIsMicrophoneEnabled] = useState(true);
diff --git a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts b/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts
index ac9c1f81..bf8dd160 100644
--- a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts
+++ b/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts
@@ -1,9 +1,14 @@
-import { Platform } from 'react-native';
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { renderHook, act } from '@testing-library/react-native';
-// Mock Platform
-const mockPlatform = Platform as jest.Mocked;
+// Mock Platform before any imports - must be inlined to avoid hoisting issues
+jest.mock('react-native', () => ({
+ Platform: {
+ OS: 'ios' as 'ios' | 'android' | 'web' | 'windows' | 'macos',
+ select: jest.fn((obj: any) => obj.ios || obj.default),
+ Version: 14,
+ },
+}));
// Mock the CallKeep service module
jest.mock('../../../../services/callkeep.service.ios', () => ({
@@ -68,7 +73,7 @@ const MockedRoom = require('livekit-client').Room as jest.MockedClass;
describe('useLiveKitCallStore with CallKeep Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockPlatform.OS = 'ios';
+ require('react-native').Platform.OS = 'ios';
// Reset mock implementations
mockCallKeepService.setup.mockResolvedValue(undefined);
@@ -112,7 +117,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
describe('CallKeep Mute State Callback', () => {
beforeEach(() => {
- mockPlatform.OS = 'ios';
+ require('react-native').Platform.OS = 'ios';
});
it('should register mute state callback when connecting on iOS', async () => {
@@ -142,7 +147,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
});
it('should not register callback on non-iOS platforms', async () => {
- mockPlatform.OS = 'android';
+ require('react-native').Platform.OS = 'android';
const { result } = renderHook(() => useLiveKitCallStore());
await act(async () => {
@@ -178,7 +183,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
});
it('should not start CallKeep call on Android', async () => {
- mockPlatform.OS = 'android';
+ require('react-native').Platform.OS = 'android';
const { result } = renderHook(() => useLiveKitCallStore());
await act(async () => {
@@ -226,7 +231,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
});
it('should not end CallKeep call on Android', async () => {
- mockPlatform.OS = 'android';
+ require('react-native').Platform.OS = 'android';
const { result } = renderHook(() => useLiveKitCallStore());
// First set up a connected state
@@ -280,7 +285,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
describe('Connection State Changes with CallKeep', () => {
it('should end CallKeep call on connection lost (iOS)', async () => {
- mockPlatform.OS = 'ios';
+ require('react-native').Platform.OS = 'ios';
// Mock the room event listener
let connectionStateListener: Function | null = null;
@@ -310,7 +315,7 @@ describe('useLiveKitCallStore with CallKeep Integration', () => {
});
it('should not end CallKeep call on Android disconnection', async () => {
- mockPlatform.OS = 'android';
+ require('react-native').Platform.OS = 'android';
// Mock the room event listener
let connectionStateListener: Function | null = null;
diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts
index a02cf478..d282d264 100644
--- a/src/features/livekit-call/store/useLiveKitCallStore.ts
+++ b/src/features/livekit-call/store/useLiveKitCallStore.ts
@@ -3,7 +3,7 @@ import { Platform } from 'react-native';
import { create } from 'zustand';
import { logger } from '../../../lib/logging';
-import { callKeepService } from '../../../services/callkeep.service.ios';
+import { callKeepService } from '../../../services/callkeep.service';
export interface RoomInfo {
id: string;
diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts
index 53151a8a..702f6c19 100644
--- a/src/hooks/__tests__/use-map-signalr-updates.test.ts
+++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts
@@ -55,8 +55,7 @@ describe('useMapSignalRUpdates', () => {
jest.clearAllTimers();
// Reset store state
- mockUseSignalRStore.mockReturnValue(0);
-
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: 0 }) : { lastUpdateTimestamp: 0});
// Mock successful API response by default
mockGetMapDataAndMarkers.mockResolvedValue(mockMapData);
});
@@ -66,8 +65,7 @@ describe('useMapSignalRUpdates', () => {
});
it('should not trigger API call when lastUpdateTimestamp is 0', () => {
- mockUseSignalRStore.mockReturnValue(0);
-
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: 0 }) : { lastUpdateTimestamp: 0});
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
// Fast forward timers to ensure debounce completes
@@ -79,7 +77,7 @@ describe('useMapSignalRUpdates', () => {
it('should trigger API call when lastUpdateTimestamp changes', async () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -99,7 +97,7 @@ describe('useMapSignalRUpdates', () => {
const { rerender } = renderHook(
(props) => {
- mockUseSignalRStore.mockReturnValue(props.timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp });
return useMapSignalRUpdates(mockOnMarkersUpdate);
},
{ initialProps: { timestamp } }
@@ -141,7 +139,7 @@ describe('useMapSignalRUpdates', () => {
const { rerender } = renderHook(
(props) => {
- mockUseSignalRStore.mockReturnValue(props.timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp });
return useMapSignalRUpdates(mockOnMarkersUpdate);
},
{ initialProps: { timestamp } }
@@ -198,7 +196,7 @@ describe('useMapSignalRUpdates', () => {
const { rerender } = renderHook(
(props) => {
- mockUseSignalRStore.mockReturnValue(props.timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp });
return useMapSignalRUpdates(mockOnMarkersUpdate);
},
{ initialProps: { timestamp: timestamp1 } }
@@ -246,7 +244,7 @@ describe('useMapSignalRUpdates', () => {
const timestamp = Date.now();
const error = new Error('API Error');
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
mockGetMapDataAndMarkers.mockRejectedValue(error);
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -271,7 +269,7 @@ describe('useMapSignalRUpdates', () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
mockGetMapDataAndMarkers.mockRejectedValue(abortError);
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -297,7 +295,7 @@ describe('useMapSignalRUpdates', () => {
const timestamp = Date.now();
const cancelError = new Error('canceled');
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
mockGetMapDataAndMarkers.mockRejectedValue(cancelError);
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -336,7 +334,7 @@ describe('useMapSignalRUpdates', () => {
}) as any;
renderHook(() => {
- mockUseSignalRStore.mockReturnValue(timestamp1);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp1 }) : { lastUpdateTimestamp: timestamp1 });
return useMapSignalRUpdates(mockOnMarkersUpdate);
});
@@ -366,7 +364,7 @@ describe('useMapSignalRUpdates', () => {
},
};
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
mockGetMapDataAndMarkers.mockResolvedValue(emptyMapData);
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -383,7 +381,7 @@ describe('useMapSignalRUpdates', () => {
it('should handle null API response', async () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
mockGetMapDataAndMarkers.mockResolvedValue(undefined as any);
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -403,7 +401,7 @@ describe('useMapSignalRUpdates', () => {
const { rerender } = renderHook(
(props) => {
- mockUseSignalRStore.mockReturnValue(props.timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp });
return useMapSignalRUpdates(mockOnMarkersUpdate);
},
{ initialProps: { timestamp } }
@@ -430,7 +428,7 @@ describe('useMapSignalRUpdates', () => {
it('should cleanup timers and abort requests on unmount', () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
// Mock AbortController
const mockAbort = jest.fn();
@@ -458,7 +456,7 @@ describe('useMapSignalRUpdates', () => {
it('should maintain stable callback reference', async () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
const secondCallback = jest.fn();
const { rerender } = renderHook(
@@ -485,7 +483,7 @@ describe('useMapSignalRUpdates', () => {
it('should log debug information for debouncing', () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
@@ -501,7 +499,7 @@ describe('useMapSignalRUpdates', () => {
it('should log successful marker updates', async () => {
const timestamp = Date.now();
- mockUseSignalRStore.mockReturnValue(timestamp);
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp });
renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
diff --git a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx
index 2ebff067..dcfa8e09 100644
--- a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx
+++ b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx
@@ -43,7 +43,14 @@ describe('useSignalRLifecycle', () => {
};
// Mock SignalR store
- mockUseSignalRStore.mockReturnValue({
+ mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ connectUpdateHub: mockConnectUpdateHub,
+ disconnectUpdateHub: mockDisconnectUpdateHub,
+ connectGeolocationHub: mockConnectGeolocationHub,
+ disconnectGeolocationHub: mockDisconnectGeolocationHub,
+ isUpdateHubConnected: false,
+ isGeolocationHubConnected: false,
+ } as any) : {
connectUpdateHub: mockConnectUpdateHub,
disconnectUpdateHub: mockDisconnectUpdateHub,
connectGeolocationHub: mockConnectGeolocationHub,
@@ -52,6 +59,16 @@ describe('useSignalRLifecycle', () => {
isGeolocationHubConnected: false,
} as any);
+ // Also mock getState for direct store access
+ (mockUseSignalRStore as any).getState = jest.fn().mockReturnValue({
+ connectUpdateHub: mockConnectUpdateHub,
+ disconnectUpdateHub: mockDisconnectUpdateHub,
+ connectGeolocationHub: mockConnectGeolocationHub,
+ disconnectGeolocationHub: mockDisconnectGeolocationHub,
+ isUpdateHubConnected: false,
+ isGeolocationHubConnected: false,
+ });
+
// Mock useAppLifecycle to return shared state
mockUseAppLifecycle.mockImplementation(() => appLifecycleState);
});
diff --git a/src/hooks/__tests__/use-toast.test.tsx b/src/hooks/__tests__/use-toast.test.tsx
index 5ce94243..74e20306 100644
--- a/src/hooks/__tests__/use-toast.test.tsx
+++ b/src/hooks/__tests__/use-toast.test.tsx
@@ -14,7 +14,9 @@ describe('useToast', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseToastStore.mockReturnValue({
+ mockUseToastStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
+ showToast: mockShowToast,
+ }) : {
showToast: mockShowToast,
});
});
diff --git a/src/hooks/use-app-lifecycle.ts b/src/hooks/use-app-lifecycle.ts
index 64023bee..ad272e20 100644
--- a/src/hooks/use-app-lifecycle.ts
+++ b/src/hooks/use-app-lifecycle.ts
@@ -3,7 +3,10 @@ import { useEffect } from 'react';
import { useAppLifecycleStore } from '@/stores/app/app-lifecycle';
export const useAppLifecycle = () => {
- const { appState, isActive, isBackground, lastActiveTimestamp } = useAppLifecycleStore();
+ const appState = useAppLifecycleStore((state) => state.appState);
+ const isActive = useAppLifecycleStore((state) => state.isActive);
+ const isBackground = useAppLifecycleStore((state) => state.isBackground);
+ const lastActiveTimestamp = useAppLifecycleStore((state) => state.lastActiveTimestamp);
useEffect(() => {
// You can add any side effects based on app state changes here
diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts
index 72764b0d..2a8ac416 100644
--- a/src/hooks/use-map-signalr-updates.ts
+++ b/src/hooks/use-map-signalr-updates.ts
@@ -17,9 +17,13 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp);
+ // Use a ref so the callback doesn't need lastUpdateTimestamp in its deps
+ const lastUpdateTimestampRef = useRef(lastUpdateTimestamp);
+ lastUpdateTimestampRef.current = lastUpdateTimestamp;
+
const fetchAndUpdateMarkers = useCallback(
async (requestedTimestamp?: number) => {
- const timestampToProcess = requestedTimestamp || lastUpdateTimestamp;
+ const timestampToProcess = requestedTimestamp || lastUpdateTimestampRef.current;
// If a fetch is in progress, queue the latest timestamp for processing after completion
if (isUpdating.current) {
@@ -112,7 +116,7 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
}
}
},
- [lastUpdateTimestamp, onMarkersUpdate]
+ [onMarkersUpdate]
);
useEffect(() => {
diff --git a/src/hooks/use-signalr-lifecycle.ts b/src/hooks/use-signalr-lifecycle.ts
index 0c055efb..d8063830 100644
--- a/src/hooks/use-signalr-lifecycle.ts
+++ b/src/hooks/use-signalr-lifecycle.ts
@@ -12,7 +12,6 @@ interface UseSignalRLifecycleOptions {
export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLifecycleOptions) {
const { isActive, appState } = useAppLifecycle();
- const signalRStore = useSignalRStore();
// Track current values with refs for timer callbacks
const currentIsActive = useRef(isActive);
@@ -62,8 +61,10 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
});
try {
+ // Use getState() to access store actions without subscribing to store changes
+ const store = useSignalRStore.getState();
// Use Promise.allSettled to prevent one failure from blocking the other
- const results = await Promise.allSettled([signalRStore.disconnectUpdateHub(), signalRStore.disconnectGeolocationHub()]);
+ const results = await Promise.allSettled([store.disconnectUpdateHub(), store.disconnectGeolocationHub()]);
// Log any failures without throwing
results.forEach((result, index) => {
@@ -86,7 +87,7 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
pendingOperations.current = null;
}
}
- }, [signalRStore]);
+ }, []);
const handleAppResume = useCallback(async () => {
logger.debug({
@@ -116,8 +117,10 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
});
try {
+ // Use getState() to access store actions without subscribing to store changes
+ const store = useSignalRStore.getState();
// Use Promise.allSettled to prevent one failure from blocking the other
- const results = await Promise.allSettled([signalRStore.connectUpdateHub(), signalRStore.connectGeolocationHub()]);
+ const results = await Promise.allSettled([store.connectUpdateHub(), store.connectGeolocationHub()]);
// Log any failures without throwing
results.forEach((result, index) => {
@@ -140,7 +143,7 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
pendingOperations.current = null;
}
}
- }, [signalRStore]);
+ }, []);
// Clear timers helper
const clearTimers = useCallback(() => {
diff --git a/src/hooks/use-status-signalr-updates.ts b/src/hooks/use-status-signalr-updates.ts
index 6765e874..6893a4c4 100644
--- a/src/hooks/use-status-signalr-updates.ts
+++ b/src/hooks/use-status-signalr-updates.ts
@@ -6,7 +6,8 @@ import { useSignalRStore } from '@/stores/signalr/signalr-store';
export const useStatusSignalRUpdates = () => {
const lastProcessedTimestamp = useRef(0);
- const { activeUnitId, setActiveUnitWithFetch } = useCoreStore();
+ const activeUnitId = useCoreStore((state) => state.activeUnitId);
+ const setActiveUnitWithFetch = useCoreStore((state) => state.setActiveUnitWithFetch);
const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp);
const lastUpdateMessage = useSignalRStore((state) => state.lastUpdateMessage);
diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts
index 7b3e31af..39022a76 100644
--- a/src/hooks/use-toast.ts
+++ b/src/hooks/use-toast.ts
@@ -1,7 +1,7 @@
import { type ToastType, useToastStore } from '../stores/toast/store';
export const useToast = () => {
- const { showToast } = useToastStore();
+ const showToast = useToastStore((state) => state.showToast);
return {
show: (type: ToastType, message: string, title?: string) => {
diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx
index 65afe997..9c98d087 100644
--- a/src/lib/auth/index.tsx
+++ b/src/lib/auth/index.tsx
@@ -1,5 +1,7 @@
// Axios interceptor setup
+import { useShallow } from 'zustand/react/shallow';
+
import useAuthStore from '../../stores/auth/store';
export { default as useAuthStore } from '../../stores/auth/store';
@@ -8,14 +10,23 @@ export * from './types';
// Utility hooks and selectors
export const useAuth = () => {
- const store = useAuthStore();
+ const { accessToken, status, error, login, logout, hydrate } = useAuthStore(
+ useShallow((state) => ({
+ accessToken: state.accessToken,
+ status: state.status,
+ error: state.error,
+ login: state.login,
+ logout: state.logout,
+ hydrate: state.hydrate,
+ }))
+ );
return {
- isAuthenticated: !!store.accessToken,
- isLoading: store.status === 'loading',
- error: store.error,
- login: store.login,
- logout: store.logout,
- status: store.status,
- hydrate: store.hydrate,
+ isAuthenticated: !!accessToken,
+ isLoading: status === 'loading',
+ error,
+ login,
+ logout,
+ status,
+ hydrate,
};
};
diff --git a/src/lib/countly-config-shim.web.ts b/src/lib/countly-config-shim.web.ts
new file mode 100644
index 00000000..91aeb381
--- /dev/null
+++ b/src/lib/countly-config-shim.web.ts
@@ -0,0 +1,28 @@
+/**
+ * CountlyConfig shim for web platform.
+ * Provides a no-op CountlyConfig constructor so that
+ * import CountlyConfig from 'countly-sdk-react-native-bridge/CountlyConfig'
+ * resolves correctly when metro redirects the subpath on web.
+ */
+
+class CountlyConfig {
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+ constructor(_serverURL?: string, _appKey?: string) {}
+ enableCrashReporting() {
+ return this;
+ }
+ setRequiresConsent(_v: boolean) {
+ return this;
+ }
+ setLoggingEnabled(_v: boolean) {
+ return this;
+ }
+ setDeviceId(_id: string) {
+ return this;
+ }
+ setParameterTamperingProtectionSalt(_salt: string) {
+ return this;
+ }
+}
+
+export default CountlyConfig;
diff --git a/src/lib/countly-shim.web.ts b/src/lib/countly-shim.web.ts
new file mode 100644
index 00000000..e9478877
--- /dev/null
+++ b/src/lib/countly-shim.web.ts
@@ -0,0 +1,100 @@
+/**
+ * Countly SDK shim for web platform.
+ * Provides no-op implementations to prevent import errors.
+ */
+
+class CountlyConfig {
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+ constructor(_serverURL?: string, _appKey?: string) {}
+ enableCrashReporting() {
+ return this;
+ }
+ setRequiresConsent(_v: boolean) {
+ return this;
+ }
+ setLoggingEnabled(_v: boolean) {
+ return this;
+ }
+ setDeviceId(_id: string) {
+ return this;
+ }
+ setParameterTamperingProtectionSalt(_salt: string) {
+ return this;
+ }
+}
+
+const Countly = {
+ CountlyConfig,
+ init: async () => {},
+ initWithConfig: async (_config?: any) => {},
+ start: async () => {},
+ stop: async () => {},
+ halt: async () => {},
+ isInitialized: () => false,
+ isLoggingEnabled: () => false,
+ enableLogging: () => {},
+ disableLogging: () => {},
+ setLoggingEnabled: () => {},
+ getCurrentDeviceId: async () => 'web-device-id',
+ changeDeviceId: async () => {},
+ setHttpPostForced: () => {},
+ enableParameterTamperingProtection: () => {},
+ setRequiresConsent: () => {},
+ giveConsent: () => {},
+ removeConsent: () => {},
+ giveAllConsent: () => {},
+ removeAllConsent: () => {},
+ events: {
+ recordEvent: () => {},
+ startEvent: () => {},
+ cancelEvent: () => {},
+ endEvent: () => {},
+ },
+ views: {
+ recordView: () => {},
+ startAutoViewTracking: () => {},
+ stopAutoViewTracking: () => {},
+ startView: () => {},
+ stopView: () => {},
+ pauseView: () => {},
+ resumeView: () => {},
+ },
+ crashes: {
+ recordException: () => {},
+ addBreadcrumb: () => {},
+ setCustomCrashSegments: () => {},
+ },
+ userProfile: {
+ setUserProperties: async () => {},
+ setProperty: () => {},
+ increment: () => {},
+ incrementBy: () => {},
+ multiply: () => {},
+ saveMax: () => {},
+ saveMin: () => {},
+ setOnce: () => {},
+ pushUnique: () => {},
+ push: () => {},
+ pull: () => {},
+ save: () => {},
+ clear: () => {},
+ },
+ feedback: {
+ presentNPS: () => {},
+ presentSurvey: () => {},
+ presentRating: () => {},
+ },
+ setUserData: async () => {},
+ setCustomUserData: async () => {},
+ recordView: async () => {},
+ setLocation: () => {},
+ disableLocation: () => {},
+ enableCrashReporting: () => {},
+ logException: () => {},
+ addCrashLog: () => {},
+ recordDirectAttribution: () => {},
+ recordIndirectAttribution: () => {},
+};
+
+export { CountlyConfig };
+export default Countly;
diff --git a/src/lib/empty-module.web.js b/src/lib/empty-module.web.js
new file mode 100644
index 00000000..4f91b911
--- /dev/null
+++ b/src/lib/empty-module.web.js
@@ -0,0 +1,5 @@
+/**
+ * Empty module shim for web platform.
+ * Used to replace build-time packages that should not be bundled.
+ */
+module.exports = {};
diff --git a/src/lib/hooks/use-preferred-bluetooth-device.ts b/src/lib/hooks/use-preferred-bluetooth-device.ts
index cb9e1b34..89b3228c 100644
--- a/src/lib/hooks/use-preferred-bluetooth-device.ts
+++ b/src/lib/hooks/use-preferred-bluetooth-device.ts
@@ -11,7 +11,8 @@ export interface PreferredBluetoothDevice {
}
export const usePreferredBluetoothDevice = () => {
- const { preferredDevice, setPreferredDevice } = useBluetoothAudioStore();
+ const preferredDevice = useBluetoothAudioStore((s) => s.preferredDevice);
+ const setPreferredDevice = useBluetoothAudioStore((s) => s.setPreferredDevice);
// Load preferred device from storage on mount
useEffect(() => {
diff --git a/src/lib/hooks/use-selected-theme.tsx b/src/lib/hooks/use-selected-theme.tsx
index 54a2e454..cb8c057d 100644
--- a/src/lib/hooks/use-selected-theme.tsx
+++ b/src/lib/hooks/use-selected-theme.tsx
@@ -1,5 +1,6 @@
import { colorScheme, useColorScheme } from 'nativewind';
import React from 'react';
+import { Platform } from 'react-native';
import { useMMKVString } from 'react-native-mmkv';
import { storage } from '../storage';
@@ -19,7 +20,11 @@ export const useSelectedTheme = () => {
const setSelectedTheme = React.useCallback(
(t: ColorSchemeType) => {
- setColorScheme(t);
+ // On web with darkMode: 'media', nativewind's colorScheme.set() throws
+ // because manual override is not supported. Only persist the preference.
+ if (Platform.OS !== 'web') {
+ setColorScheme(t);
+ }
_setTheme(t);
},
[setColorScheme, _setTheme]
@@ -30,6 +35,12 @@ export const useSelectedTheme = () => {
};
// to be used in the root file to load the selected theme from MMKV
export const loadSelectedTheme = () => {
+ // On web with darkMode: 'media', the browser handles dark mode via CSS media
+ // queries. Calling colorScheme.set() would throw because manual override is
+ // not supported in media-query mode.
+ if (Platform.OS === 'web') {
+ return;
+ }
try {
const theme = storage.getString(SELECTED_THEME);
if (theme !== undefined) {
diff --git a/src/lib/logging/index.tsx b/src/lib/logging/index.tsx
index c0351b83..3d52d5f7 100644
--- a/src/lib/logging/index.tsx
+++ b/src/lib/logging/index.tsx
@@ -1,7 +1,14 @@
+import { Platform } from 'react-native';
import { consoleTransport, logger as rnLogger } from 'react-native-logs';
import type { LogEntry, Logger, LogLevel } from './types';
+// On web, async: true wraps every log call in setTimeout which — combined with
+// Sentry's setTimeout instrumentation — creates unbounded memory growth.
+// Setting async: false on web prevents this. Severity stays 'debug' in dev
+// on all platforms so console output is visible for debugging.
+const isWeb = Platform.OS === 'web';
+
const config = {
levels: {
debug: 0,
@@ -19,7 +26,7 @@ const config = {
error: 'redBright',
},
},
- async: true,
+ async: !isWeb,
dateFormat: 'time',
printLevel: true,
printDate: true,
diff --git a/src/lib/native-module-shims.web.ts b/src/lib/native-module-shims.web.ts
index 0d1d009b..7218f048 100644
--- a/src/lib/native-module-shims.web.ts
+++ b/src/lib/native-module-shims.web.ts
@@ -184,3 +184,6 @@ export const expoAudioShim = {
getRecordingPermissionsAsync: async () => ({ granted: true, status: 'granted' }),
requestRecordingPermissionsAsync: async () => ({ granted: true, status: 'granted' }),
};
+
+// Countly SDK shim for web platform - re-exported from canonical source
+export { default as Countly, CountlyConfig } from './countly-shim.web';
diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts
index eafdc5ff..cd77b716 100644
--- a/src/services/__tests__/location-foreground-permissions.test.ts
+++ b/src/services/__tests__/location-foreground-permissions.test.ts
@@ -87,6 +87,11 @@ jest.mock('react-native', () => ({
})),
currentState: 'active',
},
+ Platform: {
+ OS: 'ios',
+ select: jest.fn((obj: any) => obj.ios || obj.default),
+ Version: 14,
+ },
}));
import * as Location from 'expo-location';
diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts
index 7a774698..1c171a71 100644
--- a/src/services/__tests__/location.test.ts
+++ b/src/services/__tests__/location.test.ts
@@ -73,6 +73,10 @@ jest.mock('react-native', () => ({
})),
currentState: 'active',
},
+ Platform: {
+ OS: 'ios',
+ select: jest.fn((options) => options.ios),
+ },
}));
import * as Location from 'expo-location';
diff --git a/src/services/__tests__/notification-sound.service.test.ts b/src/services/__tests__/notification-sound.service.test.ts
index 45a96c09..c7454f92 100644
--- a/src/services/__tests__/notification-sound.service.test.ts
+++ b/src/services/__tests__/notification-sound.service.test.ts
@@ -1,3 +1,12 @@
+// Mock react-native Platform before any imports
+jest.mock('react-native', () => ({
+ Platform: {
+ OS: 'ios',
+ select: jest.fn((obj) => obj.ios || obj.default),
+ Version: 14,
+ },
+}));
+
import { Audio } from 'expo-av';
import { notificationSoundService } from '../notification-sound.service';
diff --git a/src/services/app-initialization.service.ts b/src/services/app-initialization.service.ts
index b05f9c1e..e50cc202 100644
--- a/src/services/app-initialization.service.ts
+++ b/src/services/app-initialization.service.ts
@@ -1,7 +1,7 @@
import { Platform } from 'react-native';
import { logger } from '../lib/logging';
-import { callKeepService } from './callkeep.service.ios';
+import { callKeepService } from './callkeep.service';
import { notificationSoundService } from './notification-sound.service';
import { pushNotificationService } from './push-notification';
@@ -123,6 +123,12 @@ class AppInitializationService {
* Initialize Notification Sound Service
*/
private async _initializeNotificationSoundService(): Promise {
+ // Notification sounds are native-only
+ if (Platform.OS === 'web') {
+ logger.debug({ message: 'Notification sound service skipped on web' });
+ return;
+ }
+
try {
await notificationSoundService.initialize();
@@ -143,6 +149,12 @@ class AppInitializationService {
* Initialize Push Notification Service
*/
private async _initializePushNotifications(): Promise {
+ // Push notifications are native-only
+ if (Platform.OS === 'web') {
+ logger.debug({ message: 'Push notification service skipped on web' });
+ return;
+ }
+
try {
await pushNotificationService.initialize();
diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts
index 76bf5ea6..54d790a4 100644
--- a/src/services/audio.service.ts
+++ b/src/services/audio.service.ts
@@ -36,6 +36,12 @@ class AudioService {
return;
}
+ // Audio services are native-only; skip on web to avoid OOM from loading audio files
+ if (Platform.OS === 'web') {
+ this.isInitialized = true;
+ return;
+ }
+
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts
index 587811ee..ef545ba7 100644
--- a/src/services/bluetooth-audio.service.ts
+++ b/src/services/bluetooth-audio.service.ts
@@ -46,6 +46,7 @@ class BluetoothAudioService {
private isInitialized: boolean = false;
private hasAttemptedPreferredDeviceConnection: boolean = false;
private eventListeners: { remove: () => void }[] = [];
+ private readonly isWeb = Platform.OS === 'web';
static getInstance(): BluetoothAudioService {
if (!BluetoothAudioService.instance) {
@@ -62,6 +63,15 @@ class BluetoothAudioService {
return;
}
+ // BLE is not available on web — skip initialization entirely
+ if (Platform.OS === 'web') {
+ logger.info({
+ message: 'Bluetooth Audio Service not available on web, skipping initialization',
+ });
+ this.isInitialized = true;
+ return;
+ }
+
try {
logger.info({
message: 'Initializing Bluetooth Audio Service',
@@ -133,11 +143,11 @@ class BluetoothAudioService {
useBluetoothAudioStore.getState().setPreferredDevice(preferredDevice);
if (preferredDevice.id === 'system-audio') {
- logger.info({
- message: 'Preferred device is System Audio, ensuring no specialized device is connected',
- });
- // We are already in system audio mode by default if no device is connected
- return;
+ logger.info({
+ message: 'Preferred device is System Audio, ensuring no specialized device is connected',
+ });
+ // We are already in system audio mode by default if no device is connected
+ return;
}
// Try to connect directly to the preferred device
@@ -321,6 +331,7 @@ class BluetoothAudioService {
}
async requestPermissions(): Promise {
+ if (this.isWeb) return true;
if (Platform.OS === 'android') {
try {
const permissions = [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
@@ -347,6 +358,7 @@ class BluetoothAudioService {
}
async checkBluetoothState(): Promise {
+ if (this.isWeb) return State.PoweredOff;
try {
const bleState = await BleManager.checkState();
return this.mapBleStateToState(bleState);
@@ -360,6 +372,7 @@ class BluetoothAudioService {
}
async startScanning(durationMs: number = 10000): Promise {
+ if (this.isWeb) return;
const hasPermissions = await this.requestPermissions();
if (!hasPermissions) {
throw new Error('Bluetooth permissions not granted');
@@ -418,6 +431,7 @@ class BluetoothAudioService {
* Use this for troubleshooting device discovery issues
*/
async startDebugScanning(durationMs: number = 15000): Promise {
+ if (this.isWeb) return;
const hasPermissions = await this.requestPermissions();
if (!hasPermissions) {
throw new Error('Bluetooth permissions not granted');
@@ -591,14 +605,10 @@ class BluetoothAudioService {
private getDeviceType(device: Device): 'specialized' | 'system' {
const advertisingData = device.advertising;
const serviceUUIDs = advertisingData?.serviceUUIDs || [];
-
+
// Check for specialized PTT service UUIDs
const isSpecialized = serviceUUIDs.some((uuid: string) => {
- return [
- AINA_HEADSET_SERVICE,
- B01INRICO_HEADSET_SERVICE,
- HYS_HEADSET_SERVICE
- ].some(specialized => this.areUuidsEqual(uuid, specialized));
+ return [AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE].some((specialized) => this.areUuidsEqual(uuid, specialized));
});
if (isSpecialized) {
@@ -608,7 +618,7 @@ class BluetoothAudioService {
// Check by name for known specialized devices if UUID check fails
const name = device.name?.toLowerCase() || '';
if (name.includes('aina') || name.includes('inrico') || name.includes('hys')) {
- return 'specialized';
+ return 'specialized';
}
return 'system';
@@ -858,6 +868,7 @@ class BluetoothAudioService {
}
async stopScanning(): Promise {
+ if (this.isWeb) return;
try {
await BleManager.stopScan();
} catch (error) {
@@ -880,6 +891,7 @@ class BluetoothAudioService {
}
async connectToDevice(deviceId: string): Promise {
+ if (this.isWeb) return;
try {
useBluetoothAudioStore.getState().clearConnectionError();
useBluetoothAudioStore.getState().setIsConnecting(true);
@@ -946,8 +958,8 @@ class BluetoothAudioService {
context: { deviceId },
});
} else {
- // Ensure listener is active for system devices
- callKeepService.restoreMuteListener();
+ // Ensure listener is active for system devices
+ callKeepService.restoreMuteListener();
}
// Set up button event monitoring with peripheral info
@@ -993,42 +1005,42 @@ class BluetoothAudioService {
}
async connectToSystemAudio(): Promise {
+ if (this.isWeb) return;
try {
- logger.info({ message: 'Switching to System Audio' });
-
- // Disconnect any currently connected specialized device
- if (this.connectedDevice) {
- try {
- await BleManager.disconnect(this.connectedDevice.id);
- } catch (error) {
- logger.warn({ message: 'Error disconnecting device for System Audio switch', context: { error } });
- }
- this.connectedDevice = null;
- useBluetoothAudioStore.getState().setConnectedDevice(null);
+ logger.info({ message: 'Switching to System Audio' });
+
+ // Disconnect any currently connected specialized device
+ if (this.connectedDevice) {
+ try {
+ await BleManager.disconnect(this.connectedDevice.id);
+ } catch (error) {
+ logger.warn({ message: 'Error disconnecting device for System Audio switch', context: { error } });
}
+ this.connectedDevice = null;
+ useBluetoothAudioStore.getState().setConnectedDevice(null);
+ }
- // Ensure system audio state
- callKeepService.restoreMuteListener();
-
- // Revert LiveKit audio routing explicitly to be safe
- this.revertLiveKitAudioRouting();
-
- // Update preferred device
- const systemAudioDevice = { id: 'system-audio', name: 'System Audio' };
- useBluetoothAudioStore.getState().setPreferredDevice(systemAudioDevice);
-
- // Save to storage (implied by previous code loading it from storage, but we need to save it too?
- // usage of usePreferredBluetoothDevice hook elsewhere handles saving,
- // but here we are in service. The UI calls logic that saves it eventually
- // or we should do it here if we want persistence.)
- // The service reads from storage using require('@/lib/storage'), so we should probably save it too if we want it to persist.
- // However, the UI calls setPreferredDevice from the hook which likely saves it.
- // We will let the UI handle the persistence call or add it here if needed.
- // For now, updating the store is enough for the session.
+ // Ensure system audio state
+ callKeepService.restoreMuteListener();
+ // Revert LiveKit audio routing explicitly to be safe
+ this.revertLiveKitAudioRouting();
+
+ // Update preferred device
+ const systemAudioDevice = { id: 'system-audio', name: 'System Audio' };
+ useBluetoothAudioStore.getState().setPreferredDevice(systemAudioDevice);
+
+ // Save to storage (implied by previous code loading it from storage, but we need to save it too?
+ // usage of usePreferredBluetoothDevice hook elsewhere handles saving,
+ // but here we are in service. The UI calls logic that saves it eventually
+ // or we should do it here if we want persistence.)
+ // The service reads from storage using require('@/lib/storage'), so we should probably save it too if we want it to persist.
+ // However, the UI calls setPreferredDevice from the hook which likely saves it.
+ // We will let the UI handle the persistence call or add it here if needed.
+ // For now, updating the store is enough for the session.
} catch (error) {
- logger.error({ message: 'Failed to switch to System Audio', context: { error } });
- throw error;
+ logger.error({ message: 'Failed to switch to System Audio', context: { error } });
+ throw error;
}
}
@@ -1743,6 +1755,7 @@ class BluetoothAudioService {
}
async disconnectDevice(): Promise {
+ if (this.isWeb) return;
if (this.connectedDevice && this.connectedDevice.id) {
const deviceId = this.connectedDevice.id;
try {
@@ -1767,6 +1780,7 @@ class BluetoothAudioService {
}
async isDeviceConnected(deviceId: string): Promise {
+ if (this.isWeb) return false;
try {
const connectedPeripherals = await BleManager.getConnectedPeripherals();
return connectedPeripherals.some((p) => p.id === deviceId);
@@ -1840,6 +1854,7 @@ class BluetoothAudioService {
* Clears connections, scanning, and preferred device tracking.
*/
async reset(): Promise {
+ if (this.isWeb) return;
logger.info({
message: 'Resetting Bluetooth Audio Service state',
});
diff --git a/src/services/callkeep.service.ios.ts b/src/services/callkeep.service.ios.ts
index 46cc47c7..27777374 100644
--- a/src/services/callkeep.service.ios.ts
+++ b/src/services/callkeep.service.ios.ts
@@ -295,7 +295,7 @@ export class CallKeepService {
});
});
- // Mute/unmute events
+ // Mute/unmute events
RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.handleMutedCallAction);
}
diff --git a/src/services/callkeep.service.ts b/src/services/callkeep.service.ts
index 8d0952a7..7f704450 100644
--- a/src/services/callkeep.service.ts
+++ b/src/services/callkeep.service.ts
@@ -2,9 +2,10 @@ import { Platform } from 'react-native';
import { callKeepService as androidCallKeepService } from './callkeep.service.android';
import { callKeepService as iosCallKeepService } from './callkeep.service.ios';
+import { callKeepService as webCallKeepService } from './callkeep.service.web';
// Export the appropriate platform-specific implementation
-export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : androidCallKeepService;
+export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : Platform.OS === 'web' ? webCallKeepService : androidCallKeepService;
// Re-export types from iOS (they're the same in both)
export type { CallKeepConfig } from './callkeep.service.ios';
diff --git a/src/services/callkeep.service.web.ts b/src/services/callkeep.service.web.ts
new file mode 100644
index 00000000..0e3fd9a0
--- /dev/null
+++ b/src/services/callkeep.service.web.ts
@@ -0,0 +1,145 @@
+/**
+ * Web implementation of CallKeep service
+ * Provides no-op implementations since browsers handle audio natively
+ * and don't need CallKit/ConnectionService for background audio support
+ */
+
+import { logger } from '../lib/logging';
+
+export interface CallKeepConfig {
+ appName: string;
+ maximumCallGroups: number;
+ maximumCallsPerCallGroup: number;
+ includesCallsInRecents: boolean;
+ supportsVideo: boolean;
+ ringtoneSound?: string;
+}
+
+export class CallKeepService {
+ private static instance: CallKeepService | null = null;
+ private muteStateCallback: ((muted: boolean) => void) | null = null;
+ private endCallCallback: (() => void) | null = null;
+ private isCallActive = false;
+ private currentCallUUID: string | null = null;
+
+ private constructor() {}
+
+ static getInstance(): CallKeepService {
+ if (!CallKeepService.instance) {
+ CallKeepService.instance = new CallKeepService();
+ }
+ return CallKeepService.instance;
+ }
+
+ /**
+ * Setup CallKeep - no-op on web
+ */
+ async setup(_config: CallKeepConfig): Promise {
+ logger.debug({
+ message: 'CallKeep setup skipped - web platform handles audio natively',
+ });
+ }
+
+ /**
+ * Start a call - returns a mock UUID on web
+ */
+ async startCall(roomName: string, _handle?: string): Promise {
+ this.currentCallUUID = `web-call-${Date.now()}`;
+ this.isCallActive = true;
+
+ logger.debug({
+ message: 'CallKeep web: started call (no-op)',
+ context: { roomName, uuid: this.currentCallUUID },
+ });
+
+ return this.currentCallUUID;
+ }
+
+ /**
+ * End the active call - no-op on web
+ */
+ async endCall(): Promise {
+ if (this.currentCallUUID) {
+ logger.debug({
+ message: 'CallKeep web: ended call (no-op)',
+ context: { uuid: this.currentCallUUID },
+ });
+
+ this.currentCallUUID = null;
+ this.isCallActive = false;
+
+ // Trigger end call callback if registered
+ if (this.endCallCallback) {
+ this.endCallCallback();
+ }
+ }
+ }
+
+ /**
+ * Set a callback to handle mute state changes
+ * On web, this is managed by the UI directly, but we keep the callback for API compatibility
+ */
+ setMuteStateCallback(callback: ((muted: boolean) => void) | null): void {
+ this.muteStateCallback = callback;
+ }
+
+ /**
+ * Set a callback to handle end call events
+ */
+ setEndCallCallback(callback: (() => void) | null): void {
+ this.endCallCallback = callback;
+ }
+
+ /**
+ * Ignore mute events for a duration - no-op on web
+ */
+ ignoreMuteEvents(_durationMs: number): void {
+ // No-op on web
+ }
+
+ /**
+ * Check if there's an active call
+ */
+ isCallActiveNow(): boolean {
+ return this.isCallActive && this.currentCallUUID !== null;
+ }
+
+ /**
+ * Get the current call UUID
+ */
+ getCurrentCallUUID(): string | null {
+ return this.currentCallUUID;
+ }
+
+ /**
+ * Remove mute listener - no-op on web
+ */
+ removeMuteListener(): void {
+ // No-op on web
+ }
+
+ /**
+ * Restore mute listener - no-op on web
+ */
+ restoreMuteListener(): void {
+ // No-op on web
+ }
+
+ /**
+ * Clean up resources - minimal cleanup on web
+ */
+ async cleanup(): Promise {
+ if (this.isCallActive) {
+ await this.endCall();
+ }
+ this.muteStateCallback = null;
+ this.endCallCallback = null;
+
+ logger.debug({
+ message: 'CallKeep web: service cleaned up',
+ });
+ }
+}
+
+// Export singleton instance
+export const callKeepService = CallKeepService.getInstance();
diff --git a/src/services/location.ts b/src/services/location.ts
index 5af9f049..45a096d3 100644
--- a/src/services/location.ts
+++ b/src/services/location.ts
@@ -5,6 +5,7 @@ import { AppState, type AppStateStatus } from 'react-native';
import { setUnitLocation } from '@/api/units/unitLocation';
import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation';
import { logger } from '@/lib/logging';
+import { isWeb } from '@/lib/platform';
import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation';
import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput';
import { useCoreStore } from '@/stores/app/core-store';
@@ -58,36 +59,38 @@ const sendLocationToAPI = async (location: Location.LocationObject): Promise {
- if (error) {
- logger.error({
- message: 'Location task error',
- context: { error },
- });
- return;
- }
- if (data) {
- const { locations } = data as { locations: Location.LocationObject[] };
- const location = locations[0];
- if (location) {
- logger.info({
- message: 'Background location update received',
- context: {
- latitude: location.coords.latitude,
- longitude: location.coords.longitude,
- heading: location.coords.heading,
- },
+// Define the background task (native only — TaskManager is unsupported on web)
+if (!isWeb) {
+ TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => {
+ if (error) {
+ logger.error({
+ message: 'Location task error',
+ context: { error },
});
+ return;
+ }
+ if (data) {
+ const { locations } = data as { locations: Location.LocationObject[] };
+ const location = locations[0];
+ if (location) {
+ logger.info({
+ message: 'Background location update received',
+ context: {
+ latitude: location.coords.latitude,
+ longitude: location.coords.longitude,
+ heading: location.coords.heading,
+ },
+ });
- // Update local store
- useLocationStore.getState().setLocation(location);
+ // Update local store
+ useLocationStore.getState().setLocation(location);
- // Send to API
- await sendLocationToAPI(location);
+ // Send to API
+ await sendLocationToAPI(location);
+ }
}
- }
-});
+ });
+}
class LocationService {
private static instance: LocationService;
@@ -150,6 +153,43 @@ class LocationService {
}
async startLocationUpdates(): Promise {
+ // On web, use a lightweight browser geolocation watcher instead of expo-location/TaskManager
+ if (isWeb) {
+ if (!('geolocation' in navigator)) {
+ logger.warn({ message: 'Geolocation API not available in this browser' });
+ return;
+ }
+
+ if (!this.locationSubscription) {
+ const watchId = navigator.geolocation.watchPosition(
+ (pos) => {
+ const loc: Location.LocationObject = {
+ coords: {
+ latitude: pos.coords.latitude,
+ longitude: pos.coords.longitude,
+ altitude: pos.coords.altitude ?? 0,
+ accuracy: pos.coords.accuracy ?? 0,
+ altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
+ heading: pos.coords.heading ?? 0,
+ speed: pos.coords.speed ?? 0,
+ },
+ timestamp: pos.timestamp,
+ };
+ useLocationStore.getState().setLocation(loc);
+ sendLocationToAPI(loc);
+ },
+ (err) => {
+ logger.warn({ message: 'Web geolocation error', context: { code: err.code, msg: err.message } });
+ },
+ { enableHighAccuracy: false, maximumAge: 15000, timeout: 30000 }
+ );
+ // Store a compatible subscription object
+ this.locationSubscription = { remove: () => navigator.geolocation.clearWatch(watchId) } as unknown as Location.LocationSubscription;
+ logger.info({ message: 'Foreground location updates started' });
+ }
+ return;
+ }
+
// Load background geolocation setting first
this.isBackgroundGeolocationEnabled = await loadBackgroundGeolocationState();
@@ -231,6 +271,7 @@ class LocationService {
}
async startBackgroundUpdates(): Promise {
+ if (isWeb) return; // Background location not supported on web
if (this.backgroundSubscription || !this.isBackgroundGeolocationEnabled) {
return;
}
@@ -273,6 +314,7 @@ class LocationService {
}
async stopBackgroundUpdates(): Promise {
+ if (isWeb) return;
if (this.backgroundSubscription) {
logger.info({
message: 'Stopping background location updates',
@@ -284,6 +326,7 @@ class LocationService {
}
async updateBackgroundGeolocationSetting(enabled: boolean): Promise {
+ if (isWeb) return; // Background geolocation not applicable on web
this.isBackgroundGeolocationEnabled = enabled;
if (enabled) {
@@ -344,16 +387,23 @@ class LocationService {
async stopLocationUpdates(): Promise {
if (this.locationSubscription) {
- await this.locationSubscription.remove();
+ if (isWeb) {
+ // On web the subscription is our own shim wrapping clearWatch
+ (this.locationSubscription as any).remove();
+ } else {
+ await this.locationSubscription.remove();
+ }
this.locationSubscription = null;
}
- await this.stopBackgroundUpdates();
+ if (!isWeb) {
+ await this.stopBackgroundUpdates();
- // Check if task is registered before stopping
- const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
- if (isTaskRegistered) {
- await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
+ // Check if task is registered before stopping
+ const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
+ if (isTaskRegistered) {
+ await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
+ }
}
logger.info({
diff --git a/src/services/notification-sound.service.ts b/src/services/notification-sound.service.ts
index 7043dc5a..0d085ccd 100644
--- a/src/services/notification-sound.service.ts
+++ b/src/services/notification-sound.service.ts
@@ -1,5 +1,6 @@
import { Asset } from 'expo-asset';
import { Audio, type AVPlaybackSource, InterruptionModeIOS } from 'expo-av';
+import { Platform } from 'react-native';
import { logger } from '@/lib/logging';
import type { NotificationType } from '@/stores/push-notification/store';
@@ -46,6 +47,12 @@ class NotificationSoundService {
}
private async performInitialization(): Promise {
+ // Notification sounds are native-only; skip on web
+ if (Platform.OS === 'web') {
+ this.isInitialized = true;
+ return;
+ }
+
try {
// Configure audio mode
await Audio.setAudioModeAsync({
@@ -163,6 +170,11 @@ class NotificationSoundService {
private async playSound(sound: Audio.Sound | null, soundName: string): Promise {
try {
+ // Notification sounds are native-only; return silently on web
+ if (Platform.OS === 'web') {
+ return;
+ }
+
if (!sound) {
logger.warn({
message: `Notification sound not loaded: ${soundName}`,
diff --git a/src/services/push-notification.electron.ts b/src/services/push-notification.electron.ts
index feb09d98..6dbab667 100644
--- a/src/services/push-notification.electron.ts
+++ b/src/services/push-notification.electron.ts
@@ -168,3 +168,13 @@ class ElectronPushNotificationService {
}
export const electronPushNotificationService = ElectronPushNotificationService.getInstance();
+
+// Alias for cross-platform compatibility
+export const pushNotificationService = electronPushNotificationService;
+
+// React hook for component usage (electron stub)
+export const usePushNotifications = () => {
+ return {
+ pushToken: null,
+ };
+};
diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts
index cc477de3..07d4c68b 100644
--- a/src/services/push-notification.ts
+++ b/src/services/push-notification.ts
@@ -171,6 +171,12 @@ class PushNotificationService {
};
async initialize(): Promise {
+ // Push notifications are native-only; skip on web
+ if (Platform.OS === 'web') {
+ logger.debug({ message: 'Push notification service skipped on web' });
+ return;
+ }
+
// Set up notification channels/categories based on platform
await this.setupAndroidNotificationChannels();
await this.setupIOSNotificationCategories();
@@ -510,6 +516,9 @@ export const usePushNotifications = () => {
const previousUnitIdRef = useRef(null);
useEffect(() => {
+ // Push notifications are native-only; skip on web
+ if (Platform.OS === 'web') return;
+
// Only register if we have an active unit ID and it's different from the previous one
if (rights && activeUnitId && activeUnitId !== previousUnitIdRef.current) {
pushNotificationService
diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts
index 8dff3c81..caab70c8 100644
--- a/src/services/push-notification.web.ts
+++ b/src/services/push-notification.web.ts
@@ -318,3 +318,13 @@ class WebPushNotificationService {
}
export const webPushNotificationService = WebPushNotificationService.getInstance();
+
+// Alias for cross-platform compatibility
+export const pushNotificationService = webPushNotificationService;
+
+// React hook for component usage (web stub)
+export const usePushNotifications = () => {
+ return {
+ pushToken: null,
+ };
+};
diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts
index c3fc62a3..cb140b01 100644
--- a/src/services/signalr.service.ts
+++ b/src/services/signalr.service.ts
@@ -1,4 +1,5 @@
import { type HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr';
+import { Platform } from 'react-native';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
@@ -184,6 +185,9 @@ class SignalRService {
// Store the config for potential reconnections
this.hubConfigs.set(config.name, config);
+ // Use Warning level on web to prevent timer floods from async logger
+ const signalRLogLevel = Platform.OS === 'web' ? LogLevel.Warning : LogLevel.Information;
+
const connectionBuilder = new HubConnectionBuilder()
.withUrl(
fullUrl,
@@ -194,7 +198,7 @@ class SignalRService {
}
)
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
- .configureLogging(LogLevel.Information);
+ .configureLogging(signalRLogLevel);
const connection = connectionBuilder.build();
@@ -226,7 +230,7 @@ class SignalRService {
});
connection.on(method, (data) => {
- logger.info({
+ logger.debug({
message: `Received ${method} message from hub: ${config.name}`,
context: { method, data },
});
@@ -317,12 +321,14 @@ class SignalRService {
context: { config },
});
+ const signalRLogLevel = Platform.OS === 'web' ? LogLevel.Warning : LogLevel.Information;
+
const connection = new HubConnectionBuilder()
.withUrl(config.url, {
accessTokenFactory: () => token,
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
- .configureLogging(LogLevel.Information)
+ .configureLogging(signalRLogLevel)
.build();
// Set up event handlers
@@ -353,7 +359,7 @@ class SignalRService {
});
connection.on(method, (data) => {
- logger.info({
+ logger.debug({
message: `Received ${method} message from hub: ${config.name}`,
context: { method, data },
});
@@ -493,11 +499,7 @@ class SignalRService {
}
}
- private handleMessage(hubName: string, method: string, data: unknown): void {
- logger.debug({
- message: `Received message from hub: ${hubName}`,
- context: { method, data },
- });
+ private handleMessage(_hubName: string, method: string, data: unknown): void {
// Emit event for subscribers using the method name as the event name
this.emit(method, data);
}
@@ -611,6 +613,10 @@ class SignalRService {
this.eventListeners.get(event)?.delete(callback);
}
+ public removeAllListeners(event: string): void {
+ this.eventListeners.delete(event);
+ }
+
private emit(event: string, data: unknown): void {
this.eventListeners.get(event)?.forEach((callback) => callback(data));
}
diff --git a/src/stores/app/__tests__/audio-stream-store.test.ts b/src/stores/app/__tests__/audio-stream-store.test.ts
index 55210791..1015fd05 100644
--- a/src/stores/app/__tests__/audio-stream-store.test.ts
+++ b/src/stores/app/__tests__/audio-stream-store.test.ts
@@ -1,3 +1,12 @@
+// Mock react-native Platform before any imports
+jest.mock('react-native', () => ({
+ Platform: {
+ OS: 'ios',
+ select: jest.fn((obj: Record) => (obj as Record).ios || (obj as Record).default),
+ Version: 14,
+ },
+}));
+
// Mock dependencies first
jest.mock('@/api/voice', () => ({
getDepartmentAudioStreams: jest.fn(),
diff --git a/src/stores/app/audio-stream-store.ts b/src/stores/app/audio-stream-store.ts
index 440552df..f3d5a0ac 100644
--- a/src/stores/app/audio-stream-store.ts
+++ b/src/stores/app/audio-stream-store.ts
@@ -1,4 +1,5 @@
import { Audio, type AVPlaybackSource, type AVPlaybackStatus } from 'expo-av';
+import { Platform } from 'react-native';
import { create } from 'zustand';
import { getDepartmentAudioStreams } from '@/api/voice';
@@ -76,6 +77,12 @@ export const useAudioStreamStore = create((set, get) => ({
},
playStream: async (stream: DepartmentAudioResultStreamData) => {
+ // Audio streaming is native-only
+ if (Platform.OS === 'web') {
+ logger.debug({ message: 'Audio streaming not supported on web' });
+ return;
+ }
+
try {
const { soundObject: currentSound, stopStream } = get();
diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts
index d81af720..50793660 100644
--- a/src/stores/app/bluetooth-audio-store.ts
+++ b/src/stores/app/bluetooth-audio-store.ts
@@ -31,9 +31,9 @@ export interface BluetoothAudioDevice {
}
export interface BluetoothSystemAudioDevice {
- id: string;
- name: string;
- type: 'system';
+ id: string;
+ name: string;
+ type: 'system';
}
export interface AudioButtonEvent {
diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts
index d4163661..1517dfa7 100644
--- a/src/stores/app/core-store.ts
+++ b/src/stores/app/core-store.ts
@@ -1,5 +1,5 @@
import { Env } from '@env';
-import _ from 'lodash';
+import find from 'lodash/find';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
@@ -135,10 +135,10 @@ export const useCoreStore = create()(
if (activeUnit) {
let activeStatuses: UnitTypeStatusResultData | undefined = undefined;
const allStatuses = await getAllUnitStatuses();
- const defaultStatuses = _.find(allStatuses.Data, ['UnitType', '0']);
+ const defaultStatuses = find(allStatuses.Data, ['UnitType', '0']);
if (activeUnit.Type) {
- const statusesForType = _.find(allStatuses.Data, ['UnitType', activeUnit.Type.toString()]);
+ const statusesForType = find(allStatuses.Data, ['UnitType', activeUnit.Type.toString()]);
if (statusesForType) {
activeStatuses = statusesForType;
@@ -244,7 +244,14 @@ export const useCoreStore = create()(
fetchConfig: async () => {
try {
const config = await getConfig(Env.APP_KEY);
- set({ config: config.Data, error: null });
+ // Only update if config actually changed to prevent unnecessary re-renders
+ const current = get().config;
+ if (!current || JSON.stringify(current) !== JSON.stringify(config.Data)) {
+ set({ config: config.Data, error: null });
+ } else if (get().error) {
+ // Clear error even if config hasn't changed
+ set({ error: null });
+ }
} catch (error) {
set({ error: 'Failed to fetch config', isLoading: false });
logger.error({
@@ -258,6 +265,19 @@ export const useCoreStore = create()(
{
name: 'core-storage',
storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ activeUnitId: state.activeUnitId,
+ activeUnit: state.activeUnit,
+ activeUnitStatus: state.activeUnitStatus,
+ activeUnitStatusType: state.activeUnitStatusType,
+ activeCallId: state.activeCallId,
+ activeCall: state.activeCall,
+ activePriority: state.activePriority,
+ config: state.config,
+ activeStatuses: state.activeStatuses,
+ // Exclude: isLoading, isInitialized, isInitializing, error
+ // These are transient flags that must NOT persist across reloads
+ }),
}
)
);
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index fd24499d..71c50f4c 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -2,8 +2,9 @@ import { RTCAudioSession } from '@livekit/react-native-webrtc';
import notifee, { AndroidForegroundServiceType, AndroidImportance } from '@notifee/react-native';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
import { Audio, InterruptionModeIOS } from 'expo-av';
+import * as Device from 'expo-device';
import { Room, RoomEvent } from 'livekit-client';
-import { PermissionsAndroid, Platform } from 'react-native';
+import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native';
import { create } from 'zustand';
import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../api/voice';
@@ -15,6 +16,9 @@ import { useBluetoothAudioStore } from './bluetooth-audio-store';
// Helper function to apply audio routing
export const applyAudioRouting = async (deviceType: 'bluetooth' | 'speaker' | 'earpiece' | 'default') => {
+ // Audio routing is native-only
+ if (Platform.OS === 'web') return;
+
try {
if (Platform.OS === 'android') {
logger.info({
@@ -157,7 +161,7 @@ interface LiveKitState {
disconnectFromRoom: () => void;
fetchVoiceSettings: () => Promise;
fetchCanConnectToVoice: () => Promise;
- requestPermissions: () => Promise;
+ requestPermissions: () => Promise;
}
export const useLiveKitStore = create((set, get) => ({
@@ -230,7 +234,7 @@ export const useLiveKitStore = create((set, get) => ({
const { currentRoom } = get();
if (!currentRoom) {
logger.warn({
- message: 'Cannot toggle microphone - no active LiveKit room'
+ message: 'Cannot toggle microphone - no active LiveKit room',
});
return;
}
@@ -239,7 +243,7 @@ export const useLiveKitStore = create((set, get) => ({
await get().setMicrophoneEnabled(currentMuteState);
},
- requestPermissions: async () => {
+ requestPermissions: async (): Promise => {
try {
if (Platform.OS === 'android' || Platform.OS === 'ios') {
// Use expo-audio for both Android and iOS microphone permissions
@@ -252,7 +256,7 @@ export const useLiveKitStore = create((set, get) => ({
message: 'Microphone permission not granted',
context: { platform: Platform.OS },
});
- return;
+ return false;
}
}
@@ -265,33 +269,87 @@ export const useLiveKitStore = create((set, get) => ({
// and don't require runtime permission requests. They are automatically granted
// when the app is installed if declared in AndroidManifest.xml
if (Platform.OS === 'android') {
- // Request phone state/numbers permissions for CallKeep (required for Android 11+)
+ // Request phone state and phone numbers permissions separately
try {
- // We need these permissions to use the ConnectionService (CallKeep) properly without crashing
- const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]);
+ // Check if device has telephony capability (phones vs tablets without SIM)
+ // On non-telephony devices, READ_PHONE_STATE always returns never_ask_again
+ const hasTelephony = await Device.hasPlatformFeatureAsync('android.hardware.telephony');
- logger.debug({
- message: 'Android Phone permissions requested',
- context: { result: granted },
- });
+ if (!hasTelephony) {
+ logger.info({
+ message: 'Device does not have telephony capability - skipping phone state permission request',
+ });
+ } else {
+ // Request READ_PHONE_STATE first
+ const phoneStateResult = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE);
+ // Request READ_PHONE_NUMBERS
+ const phoneNumbersResult = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS);
+
+ logger.debug({
+ message: 'Android Phone permissions requested',
+ context: {
+ phoneStateResult,
+ phoneNumbersResult,
+ grantedValue: PermissionsAndroid.RESULTS.GRANTED,
+ },
+ });
+
+ // Only check READ_PHONE_STATE - this is the critical permission for CallKeep
+ if (phoneStateResult !== PermissionsAndroid.RESULTS.GRANTED) {
+ logger.warn({
+ message: 'Phone state permission not granted - voice functionality may be limited',
+ context: { phoneStateResult },
+ });
+
+ // On devices without telephony, never_ask_again is expected - don't alert
+ if (phoneStateResult === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN) {
+ Alert.alert(
+ 'Voice Permissions Required',
+ 'Phone state permission was permanently denied. Voice functionality may not work correctly. Please open Settings and grant the Phone permission for this app.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Open Settings', onPress: () => Linking.openSettings() },
+ ]
+ );
+ } else {
+ Alert.alert('Voice Permissions Warning', 'Phone state permission was not granted. Voice functionality may not work correctly. You can grant this permission in your device settings.', [{ text: 'OK' }]);
+ }
+ }
+ }
} catch (err) {
- logger.warn({
- message: 'Failed to request Android phone permissions',
+ logger.error({
+ message: 'Failed to request Android phone permissions - voice functionality may not work',
context: { error: err },
});
+ Alert.alert('Voice Permissions Error', 'Failed to request phone permissions. Voice functionality may not work correctly.', [{ text: 'OK' }]);
}
}
+ return true;
}
+ return true; // Web/other platforms don't need runtime permissions
} catch (error) {
logger.error({
message: 'Failed to request permissions',
context: { error, platform: Platform.OS },
});
+ return false;
}
},
connectToRoom: async (roomInfo, token) => {
try {
+ // Request permissions before connecting (critical for Android foreground service)
+ // On Android 14+, the foreground service with microphone type requires RECORD_AUDIO
+ // permission to be granted BEFORE the service starts
+ const permissionsGranted = await get().requestPermissions();
+ if (!permissionsGranted) {
+ logger.error({
+ message: 'Cannot connect to room - permissions not granted',
+ context: { roomName: roomInfo.Name },
+ });
+ return;
+ }
+
const { currentRoom, voipServerWebsocketSslAddress } = get();
// Disconnect from current room if connected
@@ -342,52 +400,51 @@ export const useLiveKitStore = create((set, get) => ({
// Setup CallKeep mute sync
callKeepService.setMuteStateCallback(async (muted) => {
logger.info({
- message: 'Syncing mute state from CallKeep',
- context: { muted }
+ message: 'Syncing mute state from CallKeep',
+ context: { muted },
});
-
+
if (room.localParticipant.isMicrophoneEnabled === muted) {
- // If CallKeep says "muted" (true), and Mic is enabled (true), we need to disable mic.
- // If CallKeep says "unmuted" (false), and Mic is disabled (false), we need to enable mic.
- // Wait, logic check:
- // isMicrophoneEnabled = true means NOT MUTED.
- // muted = true means MUTED.
- // So if isMicrophoneEnabled (true) and muted (true) -> mismatch, we must mute.
- // if isMicrophoneEnabled (false) and muted (false) -> mismatch, we must unmute.
-
- // Actually effectively: setMicrophoneEnabled(!muted)
- await room.localParticipant.setMicrophoneEnabled(!muted);
-
- // Play sound
- if (!muted) {
- await audioService.playStartTransmittingSound();
- } else {
- await audioService.playStopTransmittingSound();
- }
+ // If CallKeep says "muted" (true), and Mic is enabled (true), we need to disable mic.
+ // If CallKeep says "unmuted" (false), and Mic is disabled (false), we need to enable mic.
+ // Wait, logic check:
+ // isMicrophoneEnabled = true means NOT MUTED.
+ // muted = true means MUTED.
+ // So if isMicrophoneEnabled (true) and muted (true) -> mismatch, we must mute.
+ // if isMicrophoneEnabled (false) and muted (false) -> mismatch, we must unmute.
+
+ // Actually effectively: setMicrophoneEnabled(!muted)
+ await room.localParticipant.setMicrophoneEnabled(!muted);
+
+ // Play sound
+ if (!muted) {
+ await audioService.playStartTransmittingSound();
+ } else {
+ await audioService.playStopTransmittingSound();
+ }
}
});
-
+
// Setup CallKeep End Call sync
callKeepService.setEndCallCallback(() => {
logger.info({
- message: 'CallKeep end call event received, disconnecting room',
+ message: 'CallKeep end call event received, disconnecting room',
});
get().disconnectFromRoom();
});
- // Also ensure reverse sync: When app mutes, tell CallKeep?
+ // Also ensure reverse sync: When app mutes, tell CallKeep?
// CallKeep tracks its own state, usually triggered by UI or simple interactions.
// If we mute from within the app (e.g. on screen button), we might want to tell CallKeep we are muted.
// However, react-native-callkeep doesn't easily expose "setMuted" for the system call without ending logic or being complex?
// Actually RNCallKeep.setMutedCall(uuid, muted) exists.
-
+
const onLocalTrackMuted = () => {
- // Update CallKeep state if needed?
- // For now, let's just trust CallKeep's own state management or system UI.
+ // Update CallKeep state if needed?
+ // For now, let's just trust CallKeep's own state management or system UI.
};
-
- // We attach these listeners to the local participant if needed for other UI sync
+ // We attach these listeners to the local participant if needed for other UI sync
// Setup audio routing based on selected devices
// This may change audio modes/focus, so it comes after media button init
@@ -395,55 +452,75 @@ export const useLiveKitStore = create((set, get) => ({
await audioService.playConnectToAudioRoomSound();
- try {
- const startForegroundService = async () => {
- notifee.registerForegroundService(async () => {
- // Minimal function with no interval or tasks to reduce strain on the main thread
- return new Promise(() => {
- logger.debug({
- message: 'Foreground service registered',
+ // Android foreground service for background audio
+ // Only needed on Android - iOS uses CallKeep, web browsers handle audio natively
+ if (Platform.OS === 'android') {
+ try {
+ const startForegroundService = async () => {
+ notifee.registerForegroundService(async () => {
+ // Minimal function with no interval or tasks to reduce strain on the main thread
+ return new Promise(() => {
+ logger.debug({
+ message: 'Foreground service registered',
+ });
});
});
- });
- // Step 3: Display the notification as a foreground service
- await notifee.displayNotification({
- title: 'Active PTT Call',
- body: 'There is an active PTT call in progress.',
- android: {
- channelId: 'notif',
- asForegroundService: true,
- foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE],
- smallIcon: 'ic_launcher', // Ensure this icon exists in res/drawable
- },
- });
- };
+ // Display the notification as a foreground service
+ await notifee.displayNotification({
+ title: 'Active PTT Call',
+ body: 'There is an active PTT call in progress.',
+ android: {
+ channelId: 'notif',
+ asForegroundService: true,
+ foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE],
+ smallIcon: 'ic_launcher', // Ensure this icon exists in res/drawable
+ },
+ });
+ };
- await startForegroundService();
- } catch (error) {
- logger.error({
- message: 'Failed to register foreground service',
- context: { error },
- });
- // Don't fail the connection if foreground service fails on Android
- // The call will still work but may be killed in background
+ await startForegroundService();
+ } catch (error) {
+ logger.error({
+ message: 'Failed to register foreground service',
+ context: { error },
+ });
+ // Don't fail the connection if foreground service fails on Android
+ // The call will still work but may be killed in background
+ }
}
- // Start CallKeep call for iOS and Android background audio support
- if (Platform.OS === 'ios' || Platform.OS === 'android') {
- try {
+ // Start CallKeep call for background audio support
+ // On web, callKeepService provides no-op implementation but still tracks call state
+ try {
+ // On Android, CallKeep's VoiceConnectionService requires READ_PHONE_NUMBERS
+ // permission. If not granted, skip CallKeep to avoid a SecurityException crash.
+ let shouldStartCallKeep = true;
+ if (Platform.OS === 'android') {
+ const hasPhoneNumbers = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS);
+ const hasPhoneState = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE);
+ if (!hasPhoneNumbers || !hasPhoneState) {
+ shouldStartCallKeep = false;
+ logger.warn({
+ message: 'Skipping CallKeep - phone permissions not granted (READ_PHONE_NUMBERS or READ_PHONE_STATE)',
+ context: { hasPhoneNumbers, hasPhoneState },
+ });
+ }
+ }
+
+ if (shouldStartCallKeep) {
const callUUID = await callKeepService.startCall(roomInfo.Name || 'Voice Channel');
logger.info({
message: 'CallKeep call started for background audio support',
context: { callUUID, roomName: roomInfo.Name, platform: Platform.OS },
});
- } catch (callKeepError) {
- logger.warn({
- message: 'Failed to start CallKeep call - background audio may not work',
- context: { error: callKeepError },
- });
- // Don't fail the connection if CallKeep fails
}
+ } catch (callKeepError) {
+ logger.warn({
+ message: 'Failed to start CallKeep call - background audio may not work',
+ context: { error: callKeepError },
+ });
+ // Don't fail the connection if CallKeep fails
}
set({
@@ -467,36 +544,35 @@ export const useLiveKitStore = create((set, get) => ({
await currentRoom.disconnect();
await audioService.playDisconnectedFromAudioRoomSound();
- // End CallKeep call on iOS and Android
- if (Platform.OS === 'ios' || Platform.OS === 'android') {
+ // End CallKeep call (works on all platforms - web has no-op implementation)
+ try {
+ await callKeepService.endCall();
+ logger.debug({
+ message: 'CallKeep call ended',
+ });
+ } catch (callKeepError) {
+ logger.warn({
+ message: 'Failed to end CallKeep call',
+ context: { error: callKeepError },
+ });
+ }
+
+ // Stop Android foreground service
+ if (Platform.OS === 'android') {
try {
- await callKeepService.endCall();
- logger.debug({
- message: 'CallKeep call ended',
- });
- } catch (callKeepError) {
- logger.warn({
- message: 'Failed to end CallKeep call',
- context: { error: callKeepError },
+ await notifee.stopForegroundService();
+ } catch (error) {
+ logger.error({
+ message: 'Failed to stop foreground service',
+ context: { error },
});
}
}
- try {
- await notifee.stopForegroundService();
- } catch (error) {
- logger.error({
- message: 'Failed to stop foreground service',
- context: { error },
- });
- }
-
// Cleanup CallKeep Mute Callback
callKeepService.setMuteStateCallback(null);
callKeepService.setEndCallCallback(null);
-
-
set({
currentRoom: null,
currentRoomInfo: null,
diff --git a/src/stores/app/loading-store.ts b/src/stores/app/loading-store.ts
index ef0fdfc8..55b2a994 100644
--- a/src/stores/app/loading-store.ts
+++ b/src/stores/app/loading-store.ts
@@ -1,3 +1,4 @@
+import { useCallback, useMemo } from 'react';
import { create } from 'zustand';
interface LoadingState {
@@ -11,6 +12,11 @@ interface LoadingState {
*/
setLoading: (key: string, isLoading: boolean) => void;
+ /**
+ * Toggle loading state for a specific key
+ */
+ toggleLoading: (key: string) => void;
+
/**
* Check if a specific key is loading
*/
@@ -33,6 +39,14 @@ export const useLoadingStore = create((set, get) => ({
},
})),
+ toggleLoading: (key) =>
+ set((state) => ({
+ loadingStates: {
+ ...state.loadingStates,
+ [key]: !state.loadingStates[key],
+ },
+ })),
+
isLoading: (key) => get().loadingStates[key] || false,
resetLoading: () => set({ loadingStates: {} }),
@@ -42,12 +56,21 @@ export const useLoadingStore = create((set, get) => ({
* Hook to manage loading state for a specific key
*/
export const useLoading = (key: string) => {
- const { setLoading, isLoading } = useLoadingStore();
-
- return {
- isLoading: isLoading(key),
- startLoading: () => setLoading(key, true),
- stopLoading: () => setLoading(key, false),
- toggleLoading: () => setLoading(key, !isLoading(key)),
- };
+ const setLoading = useLoadingStore((s) => s.setLoading);
+ const toggleLoadingAction = useLoadingStore((s) => s.toggleLoading);
+ const loading = useLoadingStore((s) => s.loadingStates[key] ?? false);
+
+ const startLoading = useCallback(() => setLoading(key, true), [setLoading, key]);
+ const stopLoading = useCallback(() => setLoading(key, false), [setLoading, key]);
+ const toggleLoading = useCallback(() => toggleLoadingAction(key), [toggleLoadingAction, key]);
+
+ return useMemo(
+ () => ({
+ isLoading: loading,
+ startLoading,
+ stopLoading,
+ toggleLoading,
+ }),
+ [loading, startLoading, stopLoading, toggleLoading]
+ );
};
diff --git a/src/stores/app/location-store.ts b/src/stores/app/location-store.ts
index 1d09c189..31b793d4 100644
--- a/src/stores/app/location-store.ts
+++ b/src/stores/app/location-store.ts
@@ -47,6 +47,11 @@ export const useLocationStore = create()(
{
name: 'location-storage',
storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ // Only persist user preferences, not rapidly-changing coordinates
+ isBackgroundEnabled: state.isBackgroundEnabled,
+ isMapLocked: state.isMapLocked,
+ }),
}
)
);
diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts
index 16270a51..82290a2e 100644
--- a/src/stores/calls/store.ts
+++ b/src/stores/calls/store.ts
@@ -12,6 +12,7 @@ interface CallsState {
callPriorities: CallPriorityResultData[];
callTypes: CallTypeResultData[];
isLoading: boolean;
+ isInitialized: boolean;
error: string | null;
lastFetchedAt: number;
fetchCalls: () => Promise;
@@ -25,9 +26,14 @@ export const useCallsStore = create((set, get) => ({
callPriorities: [],
callTypes: [],
isLoading: false,
+ isInitialized: false,
error: null,
lastFetchedAt: 0,
init: async () => {
+ // Prevent re-initialization during tree remounts
+ if (get().isInitialized || get().isLoading) {
+ return;
+ }
set({ isLoading: true, error: null });
const callsResponse = await getCalls();
const callPrioritiesResponse = await getCallPriorities();
@@ -37,6 +43,7 @@ export const useCallsStore = create((set, get) => ({
callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [],
callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [],
isLoading: false,
+ isInitialized: true,
lastFetchedAt: Date.now(),
});
},
diff --git a/src/stores/roles/store.ts b/src/stores/roles/store.ts
index 1898c7fd..addd1d70 100644
--- a/src/stores/roles/store.ts
+++ b/src/stores/roles/store.ts
@@ -14,6 +14,7 @@ interface RolesState {
unitRoleAssignments: ActiveUnitRoleResultData[];
users: PersonnelInfoResultData[];
isLoading: boolean;
+ isInitialized: boolean;
error: string | null;
init: () => Promise;
fetchRoles: () => Promise;
@@ -27,8 +28,14 @@ export const useRolesStore = create((set) => ({
unitRoleAssignments: [],
users: [],
isLoading: false,
+ isInitialized: false,
error: null,
init: async () => {
+ // Prevent re-initialization during tree remounts
+ const state = useRolesStore.getState();
+ if (state.isInitialized || state.isLoading) {
+ return;
+ }
set({ isLoading: true, error: null });
try {
const response = await getAllUnitRolesAndAssignmentsForDepartment();
@@ -38,6 +45,7 @@ export const useRolesStore = create((set) => ({
roles: response.Data,
users: personnelResponse.Data,
isLoading: false,
+ isInitialized: true,
});
const activeUnit = useCoreStore.getState().activeUnit;
diff --git a/src/stores/security/store.ts b/src/stores/security/store.ts
index 66b91a79..5044ca0b 100644
--- a/src/stores/security/store.ts
+++ b/src/stores/security/store.ts
@@ -20,10 +20,13 @@ export const securityStore = create()(
getRights: async () => {
try {
const response = await getCurrentUsersRights();
-
- set({
- rights: response.Data,
- });
+ // Only update if rights actually changed to prevent unnecessary re-renders
+ const current = _get().rights;
+ if (!current || JSON.stringify(current) !== JSON.stringify(response.Data)) {
+ set({
+ rights: response.Data,
+ });
+ }
} catch (error) {
// If refresh fails, log out the user
}
@@ -32,20 +35,25 @@ export const securityStore = create()(
{
name: 'security-storage',
storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ rights: state.rights,
+ // Exclude: error (transient)
+ }),
}
)
);
export const useSecurityStore = () => {
- const store = securityStore();
+ const rights = securityStore((state) => state.rights);
+ const getRights = securityStore((state) => state.getRights);
return {
- getRights: store.getRights,
- isUserDepartmentAdmin: store.rights?.IsAdmin,
- isUserGroupAdmin: (groupId: number) => store.rights?.Groups?.some((right) => right.GroupId === groupId && right.IsGroupAdmin) ?? false,
- canUserCreateCalls: store.rights?.CanCreateCalls,
- canUserCreateNotes: store.rights?.CanAddNote,
- canUserCreateMessages: store.rights?.CanCreateMessage,
- canUserViewPII: store.rights?.CanViewPII,
- departmentCode: store.rights?.DepartmentCode,
+ getRights,
+ isUserDepartmentAdmin: rights?.IsAdmin,
+ isUserGroupAdmin: (groupId: number) => rights?.Groups?.some((right) => right.GroupId === groupId && right.IsGroupAdmin) ?? false,
+ canUserCreateCalls: rights?.CanCreateCalls,
+ canUserCreateNotes: rights?.CanAddNote,
+ canUserCreateMessages: rights?.CanCreateMessage,
+ canUserViewPII: rights?.CanViewPII,
+ departmentCode: rights?.DepartmentCode,
};
};
diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts
index 308a91c6..dc7a60fb 100644
--- a/src/stores/signalr/__tests__/signalr-store.test.ts
+++ b/src/stores/signalr/__tests__/signalr-store.test.ts
@@ -22,6 +22,7 @@ jest.mock('@/services/signalr.service', () => {
disconnectFromHub: jest.fn().mockResolvedValue(undefined),
invoke: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
+ removeAllListeners: jest.fn(),
connectToHub: jest.fn().mockResolvedValue(undefined),
disconnectAll: jest.fn().mockResolvedValue(undefined),
};
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index f0dc9cae..98fb15b8 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -51,6 +51,11 @@ export const useSignalRStore = create((set, get) => ({
return;
}
+ // Remove any previously registered handlers to prevent accumulation
+ // across reconnections or repeated connectUpdateHub calls
+ const updateEvents = ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'onConnected'];
+ updateEvents.forEach((event) => signalRService.removeAllListeners(event));
+
// Connect to the eventing hub
await signalRService.connectToHubWithEventingUrl({
name: Env.CHANNEL_HUB_NAME,
@@ -62,52 +67,27 @@ export const useSignalRStore = create((set, get) => ({
await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', parseInt(securityStore.getState().rights?.DepartmentId ?? '0'));
signalRService.on('personnelStatusUpdated', (message) => {
- logger.info({
- message: 'personnelStatusUpdated',
- context: { message },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() });
});
signalRService.on('personnelStaffingUpdated', (message) => {
- logger.info({
- message: 'personnelStaffingUpdated',
- context: { message },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() });
});
signalRService.on('unitStatusUpdated', (message) => {
- logger.info({
- message: 'unitStatusUpdated',
- context: { message },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() });
});
signalRService.on('callsUpdated', (message) => {
const now = Date.now();
-
- logger.info({
- message: 'callsUpdated',
- context: { message, now },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: now });
});
signalRService.on('callAdded', (message) => {
- logger.info({
- message: 'callAdded',
- context: { message },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() });
});
signalRService.on('callClosed', (message) => {
- logger.info({
- message: 'callClosed',
- context: { message },
- });
set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() });
});
@@ -160,6 +140,10 @@ export const useSignalRStore = create((set, get) => ({
return;
}
+ // Remove any previously registered handlers to prevent accumulation
+ const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'];
+ geoEvents.forEach((event) => signalRService.removeAllListeners(event));
+
// Connect to the geolocation hub
await signalRService.connectToHubWithEventingUrl({
name: Env.REALTIME_GEO_HUB_NAME,
diff --git a/typescript b/typescript
new file mode 100644
index 00000000..6950c187
--- /dev/null
+++ b/typescript
@@ -0,0 +1,5 @@
+Script started on Mon Feb 9 17:46:05 2026
+[1m[7m%[27m[1m[0m
[0m[27m[24m[Jshawn@MacMini3 Unit % [K[?2004h[?2004l
+[1m[7m%[27m[1m[0m
[0m[27m[24m[Jshawn@MacMini3 Unit % [K[?2004h exit[?2004l
+
+Script done on Mon Feb 9 17:46:39 2026
diff --git a/yarn.lock b/yarn.lock
index 6c0289ef..30df8c5d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -46,6 +46,15 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
+"@babel/code-frame@^7.28.6":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
+ integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.28.5"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
"@babel/compat-data@^7.27.2", "@babel/compat-data@^7.27.7":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.4.tgz#96fdf1af1b8859c8474ab39c295312bfb7c24b04"
@@ -233,6 +242,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
+"@babel/helper-validator-identifier@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+ integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
"@babel/helper-validator-option@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
@@ -272,6 +286,13 @@
dependencies:
"@babel/types" "^7.28.4"
+"@babel/parser@^7.28.6":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6"
+ integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==
+ dependencies:
+ "@babel/types" "^7.29.0"
+
"@babel/plugin-proposal-decorators@^7.12.9":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz#419c8acc31088e05a774344c021800f7ddc39bf0"
@@ -788,6 +809,15 @@
"@babel/parser" "^7.27.2"
"@babel/types" "^7.27.1"
+"@babel/template@^7.25.9":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57"
+ integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==
+ dependencies:
+ "@babel/code-frame" "^7.28.6"
+ "@babel/parser" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b"
@@ -822,6 +852,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
+"@babel/types@^7.28.6", "@babel/types@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
+ integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.28.5"
+
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -6312,6 +6350,14 @@ babel-plugin-transform-flow-enums@^0.0.2:
dependencies:
"@babel/plugin-syntax-flow" "^7.12.1"
+babel-plugin-transform-import-meta@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.3.tgz#863de841f7df37e2bf39a057572a24e4f65f3c51"
+ integrity sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==
+ dependencies:
+ "@babel/template" "^7.25.9"
+ tslib "^2.8.1"
+
babel-preset-current-node-syntax@^1.0.0, babel-preset-current-node-syntax@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6"