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 +% shawn@MacMini3 Unit % [?2004h[?2004l +% shawn@MacMini3 Unit % [?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"