Skip to content

RU-T46 Bug fixed from testing#96

Merged
ucswift merged 3 commits intomasterfrom
develop
Jan 24, 2026
Merged

RU-T46 Bug fixed from testing#96
ucswift merged 3 commits intomasterfrom
develop

Conversation

@ucswift
Copy link
Member

@ucswift ucswift commented Jan 24, 2026

Summary by CodeRabbit

  • New Features
    • Push-to-talk via headset button (configurable modes, sound feedback) with hook for easy integration and automatic/manual monitoring tied to call lifecycle; direct microphone toggle controls.
  • Bug Fixes
    • Improved keyboard/field clearing after adding call notes (iOS-friendly).
  • Improvements
    • Enhanced cross-platform image upload/handling, richer contact details display, new call layouts/navigation tweaks, added EN/ES/AR translations for headset/PTT.
  • Tests
    • Comprehensive unit tests for headset button service and hook.
  • Documentation
    • Headset PTT usage and configuration docs added.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 24, 2026

📝 Walkthrough

Walkthrough

Adds a headset button (PTT) feature with a new service and hook, integrates monitoring and microphone controls into LiveKit/Bluetooth stores, adds UI/layout/tests, image and contact rendering improvements, translations, a microphone-toggle utility, and documentation.

Changes

Cohort / File(s) Change Summary
Headset Button PTT Service & Tests
src/services/headset-button.service.ts, src/services/__tests__/headset-button.service.test.ts
New singleton HeadsetButtonService: media button listeners, type mapping (single/double/long), PTT modes/config, monitoring lifecycle, mic control integration, simulate/destroy APIs, and comprehensive unit tests.
PTT Hook & Hook Tests
src/lib/hooks/use-headset-button-ptt.ts, src/lib/hooks/__tests__/use-headset-button-ptt.test.ts
New useHeadsetButtonPTT hook exposing monitoring controls, config sync, auto-start/stop on LiveKit connect/disconnect, microphone controls, simulation, and tests covering lifecycle and behaviors.
Bluetooth Audio Store Extensions
src/stores/app/bluetooth-audio-store.ts
Added PttMode, HeadsetButtonConfig, isHeadsetButtonMonitoring state, headsetButtonConfig state, and actions setIsHeadsetButtonMonitoring / setHeadsetButtonConfig.
LiveKit Store Integrations
src/stores/app/livekit-store.ts, src/utils/microphone-toggle.ts
Added toggleMicrophone, setMicrophoneEnabled, startHeadsetButtonMonitoring, stopHeadsetButtonMonitoring; auto-start/stop monitoring on connect/disconnect; new shared toggleMicrophone utility with logging, Bluetooth updates, and optional sound feedback.
Call Routing Layouts & Tests
src/app/_layout.tsx, src/app/call/_layout.tsx, src/app/call/[id]/_layout.tsx, src/app/call/shared-options.ts, src/app/call/__tests__/_layout.test.tsx, src/app/call/[id]/__tests__/_layout.test.tsx
New Expo Router call layouts (CallLayout, CallIdLayout) using shared options (headerBackVisible false; Android animation) and unit tests verifying Stack rendering/config.
Call Edit / Detail Update
src/app/call/[id]/edit.tsx
useCallDetailStore now exposes callExtraData; edit screen pre-populates dispatch selections from callExtraData.Dispatches, updates effects/deps and form reset logic.
Image & Notes UI Improvements
src/components/calls/call-images-modal.tsx, src/components/calls/call-notes-modal.tsx
call-images-modal: refactored picker/camera flows, platform-specific result handling, improved error/logging and base64/FileSystem fallbacks, per-item load/error handling; call-notes-modal: defer clearing input after keyboard dismissal.
Content Rendering & Scroll Layouts
src/components/calendar/calendar-item-details-sheet.tsx, src/components/contacts/contact-details-sheet.tsx
Adjusted ScrollView flex/padding; contact details now render sanitized HTML via WebView (HtmlContentField) and uses safe date formatting.
Translations
src/translations/en.json, src/translations/es.json, src/translations/ar.json
Added common keys (ok, permission_denied, uploading), contacts.descriptionLabel, and multiple LiveKit/headset/PTT keys (modes, actions, monitoring states, sound feedback).
Docs & Misc
docs/headset-button-ptt-implementation.md, .github/copilot-instructions.md, src/components/shifts/shift-details-sheet.tsx
Added PTT implementation docs, Expo prebuild guidance, and a minor CSS class reorder tweak.

Sequence Diagram(s)

sequenceDiagram
    participant Device as Device/Media Button
    participant HBS as HeadsetButton<br/>Service
    participant LKS as LiveKit<br/>Store
    participant BAS as Bluetooth Audio<br/>Store
    participant Audio as Audio Service

    Device->>HBS: media button event (type, timestamp)
    HBS->>HBS: map type -> event (single/double/long)
    HBS->>HBS: evaluate config (pttMode, mappings)
    alt mapped to mic control
        HBS->>LKS: toggleMicrophone()/setMicrophoneEnabled()
        LKS->>LKS: update participant mic state
        LKS->>BAS: set lastAction / monitoring state
        LKS->>Audio: play feedback (if enabled)
        Audio-->>LKS: feedback played
        LKS-->>HBS: mic state confirmed
    else no action
        HBS->>HBS: ignore or await further events
    end
    HBS->>BAS: record button event/timestamp
    BAS-->>HBS: ack
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • CU-868ex18rd Fixing the call images view. #72 — Overlaps src/components/calls/call-images-modal.tsx (image picker/upload flow and rendering changes).
  • Develop #58 — Related to Bluetooth audio / LiveKit integration surfaces and state changes in src/stores/app/bluetooth-audio-store.ts.

Poem

🐰
I tap my ear and hear a click,
The headset listens, quick and slick,
Toggle, press, or hold to speak,
Routes and strings now find their peak,
A rabbit cheers — PTT’s here to seek!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'RU-T46 Bug fixed from testing' is vague and does not describe the actual changes in the pull request. The PR introduces a major headset button PTT feature with new services, hooks, store integrations, and layout components, but the title only mentions 'bug fixed' without specificity. Replace the title with a clear, specific description of the main change, such as 'Add headset button PTT functionality for AirPods/Bluetooth earbuds' or 'Implement push-to-talk control via headset buttons'. Avoid vague terms like 'bug fixed' and include the core feature being added.
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/stores/app/livekit-store.ts (1)

89-101: Interface mismatch: disconnectFromRoom signature.

The interface declares disconnectFromRoom: () => void; but the implementation at line 280 is disconnectFromRoom: async () => {...}. This creates a type inconsistency.

🔧 Fix interface signature
   // Room operations
   connectToRoom: (roomInfo: DepartmentVoiceChannelResultData, token: string) => Promise<void>;
-  disconnectFromRoom: () => void;
+  disconnectFromRoom: () => Promise<void>;
🤖 Fix all issues with AI agents
In `@src/app/call/_layout.tsx`:
- Around line 7-14: The TypeScript error comes from passing
headerBackTitleVisible inside the Stack's screenOptions where expo-router's
types don't include it; update the screenOptions to use a type assertion so TS
accepts the extra property—e.g. cast the object to any or to the library's
screen options type with an intersection that includes headerBackTitleVisible—so
modify the Stack call (symbol: Stack, prop: screenOptions) in _layout.tsx to
assert the options object (including the Platform.OS === 'android' animation
branch) as any/unknown as the expected type, preserving the
headerBackTitleVisible usage without changing runtime behavior.

In `@src/app/call/`[id]/_layout.tsx:
- Around line 7-14: The TypeScript error comes from headerBackTitleVisible not
matching the expected screenOptions type on the Stack component; update the
screenOptions object to a correctly-typed options object by importing the
NativeStackNavigationOptions type (or appropriate navigation options type used
by your Stack) and asserting the screenOptions as that type, e.g. add an import
for NativeStackNavigationOptions and change screenOptions to: screenOptions={ ({
headerBackTitleVisible: false, ...(Platform.OS === 'android' && { animation:
'slide_from_right' }) } as NativeStackNavigationOptions) }; this keeps
headerBackTitleVisible while satisfying the Stack's type checker (alternatively
cast to any if you prefer a quicker bypass).

In `@src/components/calls/call-images-modal.tsx`:
- Around line 175-176: The filename extension is hardcoded to .jpg while native
images are encoded as PNG; update the logic around setSelectedImageInfo and the
uploadCallImage invocation to derive the extension from the actual encoding
format (use .png when ImageManipulator.SaveFormat.PNG is used on native, .jpg
for JPEG/web), or switch native encoding to JPEG so names match bytes; ensure
the computed filename passed to setSelectedImageInfo({ uri, filename }) and
uploadCallImage(...) uses that derived extension so uploaded bytes and file
extension/MIME are consistent.

In `@src/components/contacts/contact-details-sheet.tsx`:
- Around line 139-187: HtmlContentField currently injects raw user HTML into the
WebView, so replace the direct interpolation of value with a sanitized string
produced by sanitizeHtmlContent(value) imported from src/utils/webview-html.ts,
and harden the WebView props to follow the defaultWebViewProps pattern (use
originWhitelist: ['about:'], set javaScriptEnabled: false and domStorageEnabled:
false) while keeping androidLayerType as needed; see
calendar-item-details-sheet.tsx for the exact sanitization + prop pattern and
update the WebView usage in HtmlContentField accordingly.

In `@src/services/__tests__/headset-button.service.test.ts`:
- Around line 224-258: The Button Type Mapping describe block lacks isolated
setup/teardown causing leaked mock calls; add a beforeEach that calls
jest.clearAllMocks() (or mockAddButtonEvent.mockClear()), re-initializes
headsetButtonService and calls headsetButtonService.startMonitoring() as needed
for each test, and add an afterEach that calls
headsetButtonService.stopMonitoring() to clean up; reference the describe block
and the methods startMonitoring, stopMonitoring, simulateButtonPress and the
mock mockAddButtonEvent when making these changes so each test runs with a fresh
mock/state.

In `@src/stores/app/livekit-store.ts`:
- Around line 445-461: The try/catch in startHeadsetButtonMonitoring won't catch
rejections from headsetButtonService.initialize() because the code uses .then();
change the function to handle async errors by making
startHeadsetButtonMonitoring async and using await for
headsetButtonService.initialize() (then call
headsetButtonService.startMonitoring() and update useBluetoothAudioStore) inside
the try block, so any thrown/rejected error is caught by the catch;
alternatively, if you prefer promises, add a .catch() on
headsetButtonService.initialize() to log the error via logger.error with the
same context.
🧹 Nitpick comments (17)
src/app/call/__tests__/_layout.test.tsx (3)

3-3: Unused import.

View is imported but not used in the test file body—it's re-imported inside the mock factory on line 8.

🔧 Remove unused import
 import { render } from '@testing-library/react-native';
 import React from 'react';
-import { View } from 'react-native';

28-32: Test doesn't verify screenOptions configuration.

The second test has the same assertions as the first and doesn't actually verify that screenOptions are configured correctly. Consider inspecting the props passed to the mock Stack.

🧪 Suggested improvement to actually test screenOptions
   it('should configure Stack with proper screenOptions', () => {
-    const { getByTestId } = render(<CallLayout />);
-    const stack = getByTestId('mock-stack');
-    expect(stack).toBeDefined();
+    const { getByTestId } = render(<CallLayout />);
+    const stack = getByTestId('mock-stack');
+    expect(stack.props.screenOptions).toBeDefined();
+    expect(stack.props.screenOptions.headerBackTitleVisible).toBe(false);
   });

9-9: Avoid using any type.

The mock uses any for props and ref parameters. Per coding guidelines, avoid any and use precise types.

🔧 Use proper types
-  const MockStack = React.forwardRef((props: any, ref: any) => (
+  const MockStack = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<View>) => (
src/app/call/_layout.tsx (1)

5-16: Code duplication with CallIdLayout.

This component is nearly identical to src/app/call/[id]/_layout.tsx. Consider extracting the shared screenOptions to a constant or helper to reduce duplication.

♻️ Extract shared screenOptions

Create a shared configuration file, e.g., src/app/call/shared-options.ts:

import { Platform } from 'react-native';
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';

export const callStackScreenOptions: NativeStackNavigationOptions = {
  headerBackTitleVisible: false,
  ...(Platform.OS === 'android' && {
    animation: 'slide_from_right',
  }),
};

Then import and use in both layouts:

import { callStackScreenOptions } from './shared-options';

export default function CallLayout() {
  return <Stack screenOptions={callStackScreenOptions} />;
}
src/app/call/[id]/__tests__/_layout.test.tsx (3)

3-3: Unused import.

View is imported but not used—it's re-imported inside the mock factory.

🔧 Remove unused import
 import { render } from '@testing-library/react-native';
 import React from 'react';
-import { View } from 'react-native';

6-18: Duplicate mock setup across test files.

This mock is identical to the one in src/app/call/__tests__/_layout.test.tsx. Consider extracting a shared mock to a test utility file.

♻️ Extract shared mock to test utility

Create src/test-utils/mocks/expo-router.ts:

import React from 'react';
import { View } from 'react-native';

export const createMockStack = () => {
  const MockStack = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<typeof View>) => (
    <View ref={ref as React.Ref<View>} testID="mock-stack" {...props}>
      {props.children as React.ReactNode}
    </View>
  ));
  MockStack.Screen = jest.fn(() => null);
  return { Stack: MockStack };
};

Then in test files:

jest.mock('expo-router', () => require('@/test-utils/mocks/expo-router').createMockStack());

28-32: Test doesn't verify screenOptions configuration.

Same issue as the other test file—the second test doesn't actually verify screenOptions are configured.

🧪 Suggested improvement
   it('should configure Stack with proper screenOptions', () => {
-    const { getByTestId } = render(<CallIdLayout />);
-    const stack = getByTestId('mock-stack');
-    expect(stack).toBeDefined();
+    const { getByTestId } = render(<CallIdLayout />);
+    const stack = getByTestId('mock-stack');
+    expect(stack.props.screenOptions).toBeDefined();
+    expect(stack.props.screenOptions.headerBackTitleVisible).toBe(false);
   });
src/app/call/[id]/_layout.tsx (1)

5-16: Implementation identical to CallLayout.

This component is a duplicate of src/app/call/_layout.tsx. If both need identical configuration, extract to a shared constant as mentioned in the earlier review comment.

src/stores/app/bluetooth-audio-store.ts (1)

50-60: Duplicate type definitions with headset-button.service.ts.

PttMode and HeadsetButtonConfig are defined identically in both this file and src/services/headset-button.service.ts (lines 47-55). This violates DRY and can lead to drift if one is updated without the other.

Consider importing these types from a single source of truth.

♻️ Suggested approach

Either:

  1. Export types from the service and import them here:
-// PTT mode for headset button control
-export type PttMode = 'toggle' | 'push_to_talk' | 'disabled';
-
-// Headset button configuration
-export interface HeadsetButtonConfig {
-  pttMode: PttMode;
-  playPauseAction: 'toggle_mute' | 'none';
-  doubleClickAction: 'toggle_mute' | 'none';
-  longPressAction: 'toggle_mute' | 'none';
-  soundFeedback: boolean;
-}
+export { type PttMode, type HeadsetButtonConfig } from '@/services/headset-button.service';
  1. Or create a shared types file (e.g., src/types/headset-button.types.ts) and import from there in both files.
src/services/headset-button.service.ts (5)

14-14: Unused imports: NativeEventEmitter and NativeModules.

These are imported but never used in the file.

🧹 Remove unused imports
-import { AppState, DeviceEventEmitter, NativeEventEmitter, NativeModules, Platform } from 'react-native';
+import { AppState, DeviceEventEmitter, Platform } from 'react-native';

19-37: Lazy loading and dynamic imports introduce runtime fragility.

The lazy loading pattern for liveKitStoreModule and try/catch for audioService are pragmatic for avoiding circular dependencies and test issues, but:

  1. Lines 20-21, 31: Uses any type which violates coding guidelines ("Avoid using any; use precise types")
  2. Line 35: Empty catch block silently swallows errors - at minimum, log a warning
♻️ Suggested improvements
 // Import livekit store lazily to avoid circular dependency
-// eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-let liveKitStoreModule: any = null;
+import type { useLiveKitStore as LiveKitStoreType } from '@/stores/app/livekit-store';
+let liveKitStoreModule: { useLiveKitStore: typeof LiveKitStoreType } | null = null;
 const getLiveKitStore = () => {
   if (!liveKitStoreModule) {
     // eslint-disable-next-line `@typescript-eslint/no-var-requires`
     liveKitStoreModule = require('@/stores/app/livekit-store');
   }
   return liveKitStoreModule.useLiveKitStore;
 };

 // Import audioService dynamically to avoid expo module import errors in tests
-let audioService: any;
+import type { audioService as AudioServiceType } from '@/services/audio.service';
+let audioService: Partial<typeof AudioServiceType> = {};
 try {
   // eslint-disable-next-line `@typescript-eslint/no-var-requires`
   audioService = require('@/services/audio.service').audioService;
 } catch {
-  audioService = {};
+  logger.warn({ message: 'audioService could not be loaded, sound feedback will be unavailable' });
 }

72-77: Unused class properties: longPressTimer and isButtonHeld.

These properties are declared and managed in stopMonitoring() but the long press detection logic is never implemented. The documentation (line 154) also notes "push_to_talk (not yet implemented)".

Consider either implementing the feature or removing dead code to avoid confusion.


198-213: Use precise types instead of any for event handlers.

Multiple handler methods use any for event parameters, which violates coding guidelines.

♻️ Define event interfaces
+interface RawHeadsetButtonEvent {
+  type?: string;
+  keyCode?: number;
+  source?: string;
+  deviceName?: string;
+}
+
 /**
  * Handle headset button events
  */
-private handleHeadsetButtonEvent(event: any): void {
+private handleHeadsetButtonEvent(event: RawHeadsetButtonEvent): void {
   if (!this.isMonitoring) return;
   // ...
 }

Apply similar typing to handleRemoteControlEvent, handleMediaButtonEvent, handleAudioRouteChange, and handleHeadsetConnectionChange.


493-535: Good implementation of toggleMicrophone with proper error handling.

The method correctly:

  • Checks for active room before proceeding
  • Uses try/catch for error handling
  • Updates the Bluetooth audio store
  • Conditionally plays sound feedback

However, there's a minor logic concern: the variable currentMuteState is confusingly named - it actually represents the new enabled state, not the current mute state.

♻️ Improve variable naming for clarity
-  const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
-  await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState);
+  const newEnabledState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
+  await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(newEnabledState);
src/stores/app/livekit-store.ts (1)

371-405: Duplicate microphone toggle logic.

This toggleMicrophone implementation duplicates nearly identical logic from headset-button.service.ts (lines 493-535). Both:

  • Check for active room
  • Toggle microphone state
  • Update Bluetooth audio store
  • Play sound feedback

Consider extracting shared logic to avoid drift and maintenance burden.

src/lib/hooks/use-headset-button-ptt.ts (2)

122-132: Effect dependencies won't trigger re-configuration after mount.

The pttMode and soundFeedback are in the dependency array, but the effect is guarded by initialSetupDone.current, so changing these props after mount won't update the service configuration. This could lead to unexpected behavior.

Either:

  1. Remove them from deps if one-time init is intentional (add eslint-disable comment)
  2. Or separate the initialization from configuration updates
♻️ Separate initialization from configuration
   // Initialize service on mount
   useEffect(() => {
     if (!initialSetupDone.current) {
       headsetButtonService.initialize();
-      headsetButtonService.setConfig({
-        pttMode,
-        soundFeedback,
-      });
       initialSetupDone.current = true;
     }
-  }, [pttMode, soundFeedback]);
+  }, []);
+
+  // Update config when options change
+  useEffect(() => {
+    if (initialSetupDone.current) {
+      headsetButtonService.setConfig({
+        pttMode,
+        soundFeedback,
+      });
+    }
+  }, [pttMode, soundFeedback]);

134-141: Potential race condition with auto-start on connect.

If autoStartOnConnect is true and the user connects to LiveKit, this effect will call startHeadsetButtonMonitoring(). However, connectToRoom in the LiveKit store (lines 248-263) also starts monitoring. This could result in duplicate initialization attempts.

The service's startMonitoring() guards against this with an early return, but the redundancy is worth noting.

Comment on lines 7 to 14
<Stack
screenOptions={{
headerBackTitleVisible: false,
...(Platform.OS === 'android' && {
animation: 'slide_from_right',
}),
}}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

TypeScript error on headerBackTitleVisible.

Same TypeScript error as in the root layout—headerBackTitleVisible is not recognized in expo-router's types.

🐛 Proposed fix using type assertion
+import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
+
 export default function CallLayout() {
   return (
     <Stack
       screenOptions={{
         headerBackTitleVisible: false,
         ...(Platform.OS === 'android' && {
           animation: 'slide_from_right',
         }),
-      }}
+      } as NativeStackNavigationOptions}
     />
   );
 }
🧰 Tools
🪛 GitHub Check: test

[failure] 9-9:
Object literal may only specify known properties, and 'headerBackTitleVisible' does not exist in type 'NativeStackNavigationOptions | ((props: { route: RouteProp<ParamListBase, string>; navigation: NativeStackNavigationProp<ParamListBase, string, undefined>; theme: Theme; }) => NativeStackNavigationOptions)'.

🤖 Prompt for AI Agents
In `@src/app/call/_layout.tsx` around lines 7 - 14, The TypeScript error comes
from passing headerBackTitleVisible inside the Stack's screenOptions where
expo-router's types don't include it; update the screenOptions to use a type
assertion so TS accepts the extra property—e.g. cast the object to any or to the
library's screen options type with an intersection that includes
headerBackTitleVisible—so modify the Stack call (symbol: Stack, prop:
screenOptions) in _layout.tsx to assert the options object (including the
Platform.OS === 'android' animation branch) as any/unknown as the expected type,
preserving the headerBackTitleVisible usage without changing runtime behavior.

Comment on lines +224 to +258
describe('Button Type Mapping', () => {
// Test the internal button type mapping through public interface
it('should handle play_pause button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('play_pause');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});

it('should handle hook button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('hook');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});

it('should handle unknown button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('next');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'unknown',
})
);
headsetButtonService.stopMonitoring();
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "headset-button.service.test.ts" -type f

Repository: Resgrid/Responder

Length of output: 116


🏁 Script executed:

cat -n src/services/__tests__/headset-button.service.test.ts

Repository: Resgrid/Responder

Length of output: 9882


Add setup/teardown to the Button Type Mapping describe block to ensure mock isolation.

This describe block is outside the main HeadsetButtonService scope, so the main beforeEach (which clears mocks) does not apply. Tests can accumulate mock calls from previous tests, causing false positives. Add a beforeEach to clear mocks and initialize the service, plus an afterEach to clean up.

Suggested setup/teardown
 describe('Button Type Mapping', () => {
+  beforeEach(async () => {
+    jest.clearAllMocks();
+    await headsetButtonService.initialize();
+  });
+
+  afterEach(() => {
+    headsetButtonService.stopMonitoring();
+  });
   // Test the internal button type mapping through public interface
   it('should handle play_pause button type', () => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('Button Type Mapping', () => {
// Test the internal button type mapping through public interface
it('should handle play_pause button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('play_pause');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});
it('should handle hook button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('hook');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});
it('should handle unknown button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('next');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'unknown',
})
);
headsetButtonService.stopMonitoring();
});
});
describe('Button Type Mapping', () => {
beforeEach(async () => {
jest.clearAllMocks();
await headsetButtonService.initialize();
});
afterEach(() => {
headsetButtonService.stopMonitoring();
});
// Test the internal button type mapping through public interface
it('should handle play_pause button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('play_pause');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});
it('should handle hook button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('hook');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'mute',
})
);
headsetButtonService.stopMonitoring();
});
it('should handle unknown button type', () => {
headsetButtonService.startMonitoring();
headsetButtonService.simulateButtonPress('next');
expect(mockAddButtonEvent).toHaveBeenCalledWith(
expect.objectContaining({
button: 'unknown',
})
);
headsetButtonService.stopMonitoring();
});
});
🤖 Prompt for AI Agents
In `@src/services/__tests__/headset-button.service.test.ts` around lines 224 -
258, The Button Type Mapping describe block lacks isolated setup/teardown
causing leaked mock calls; add a beforeEach that calls jest.clearAllMocks() (or
mockAddButtonEvent.mockClear()), re-initializes headsetButtonService and calls
headsetButtonService.startMonitoring() as needed for each test, and add an
afterEach that calls headsetButtonService.stopMonitoring() to clean up;
reference the describe block and the methods startMonitoring, stopMonitoring,
simulateButtonPress and the mock mockAddButtonEvent when making these changes so
each test runs with a fresh mock/state.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/calls/call-images-modal.tsx (1)

344-356: Fallback encoding format creates filename/content mismatch.

When the primary PNG manipulation fails on native platforms, the fallback uses SaveFormat.JPEG (line 352) while the filename retains the .png extension set earlier (lines 176, 195, 253, 272). This causes the same filename/encoding mismatch that was previously identified - JPEG bytes uploaded under a .png filename.

Consider using SaveFormat.PNG in the fallback to maintain consistency, or update the filename when falling back to JPEG encoding.

🛠️ Suggested fix - use PNG in fallback for consistency
         } catch (manipulateError) {
           console.error('Error manipulating image:', manipulateError);
           // Try without resize as fallback for problematic images
           manipulatedImage = await ImageManipulator.manipulateAsync(
             selectedImageInfo.uri,
             [], // No transformations
             {
               compress: 0.8,
-              format: ImageManipulator.SaveFormat.JPEG,
+              format: ImageManipulator.SaveFormat.PNG,
               base64: true,
             }
           );
         }
🤖 Fix all issues with AI agents
In @.github/copilot-instructions.md:
- Line 51: Update the sentence "This is an expo managed project that uses
prebuild, do not make native code changes outside of expo prebuild
capabilities." to use the hyphenated and capitalized form "Expo‑managed" and
adjust capitalization as needed so it reads naturally (e.g., "This is an
Expo‑managed project that uses prebuild; do not make native code changes outside
of Expo prebuild capabilities.")—locate and replace the exact string in the
document.

In `@src/services/headset-button.service.ts`:
- Around line 431-440: The setTimeout callback calls the async method
executeButtonAction(event, this.buttonPressCount) but does not handle promise
rejections; wrap the call so any thrown errors are caught (e.g., use an async
IIFE or call executeButtonAction(...).catch(...)) and ensure buttonPressCount is
reset after handling; modify the block using the existing symbols
buttonPressTimer, executeButtonAction, buttonPressCount and
DOUBLE_CLICK_THRESHOLD to call executeButtonAction safely and log or handle
errors to avoid unhandled promise rejections.

In `@src/stores/app/livekit-store.ts`:
- Around line 100-101: The startHeadsetButtonMonitoring signature is typed as ()
=> void but its implementation is async (await
headsetButtonService.initialize()), so change the interface/type to () =>
Promise<void> for startHeadsetButtonMonitoring in livekit-store (and any
exported types referencing it), then update/verify call sites such as
use-headset-button-ptt.ts to await or handle the returned promise (e.g., await
store.startHeadsetButtonMonitoring() or attach .catch) to properly propagate
rejections and avoid unhandled promise errors.
🧹 Nitpick comments (5)
src/components/calls/call-images-modal.tsx (1)

440-470: Consider extracting event handlers to avoid anonymous functions.

Per coding guidelines, anonymous functions in event handlers can cause unnecessary re-renders. The onPress, onError, and onLoad handlers create new function instances on each render.

Since these handlers need access to item context, consider using a memoized callback with item ID or extracting to a separate component.

♻️ Example approach - extract to memoized callbacks
const handleImagePress = useCallback((imageSource: { uri: string }, itemName?: string) => {
  setFullScreenImage({ source: imageSource, name: itemName });
}, []);

const handleImageLoad = useCallback((itemId: string) => {
  setImageErrors((prev) => {
    const newSet = new Set(prev);
    newSet.delete(itemId);
    return newSet;
  });
}, []);

// Then in renderImageItem:
onPress={() => handleImagePress(imageSource, item.Name)}
onLoad={() => handleImageLoad(item.Id)}
onError={() => handleImageError(item.Id, 'expo-image load error')}
src/components/contacts/contact-details-sheet.tsx (1)

497-505: Guard against invalid date parsing before formatting.

If parseDateISOString returns an invalid date, formatDateForDisplay may throw or display “Invalid Date.” Consider parsing once and guarding the result.

♻️ Proposed refactor
+  const addedOnDate = selectedContact.AddedOn ? parseDateISOString(selectedContact.AddedOn) : null;
+  const editedOnDate = selectedContact.EditedOn ? parseDateISOString(selectedContact.EditedOn) : null;

   ...
-  value={selectedContact.AddedOn ? formatDateForDisplay(parseDateISOString(selectedContact.AddedOn), 'yyyy-MM-dd HH:mm') : undefined}
+  value={addedOnDate ? formatDateForDisplay(addedOnDate, 'yyyy-MM-dd HH:mm') : undefined}

   ...
-  value={selectedContact.EditedOn ? formatDateForDisplay(parseDateISOString(selectedContact.EditedOn), 'yyyy-MM-dd HH:mm') : undefined}
+  value={editedOnDate ? formatDateForDisplay(editedOnDate, 'yyyy-MM-dd HH:mm') : undefined}
src/utils/microphone-toggle.ts (1)

35-67: Rename the toggle state variable for clarity.
currentMuteState actually represents the next “enabled” state; a clearer name reduces confusion in logs and actions.

♻️ Suggested refactor
-    const currentMuteState = !room.localParticipant.isMicrophoneEnabled;
-    await room.localParticipant.setMicrophoneEnabled(currentMuteState);
+    const nextMicrophoneEnabled = !room.localParticipant.isMicrophoneEnabled;
+    await room.localParticipant.setMicrophoneEnabled(nextMicrophoneEnabled);

-      context: { enabled: currentMuteState },
+      context: { enabled: nextMicrophoneEnabled },

-      action: currentMuteState ? 'unmute' : 'mute',
+      action: nextMicrophoneEnabled ? 'unmute' : 'mute',

-      if (currentMuteState) {
+      if (nextMicrophoneEnabled) {
src/lib/hooks/__tests__/use-headset-button-ptt.test.ts (1)

59-81: Avoid as any in mocked store return values.
Use typed assertions to keep tests aligned with store contracts (apply similarly to other mock returns). As per coding guidelines, avoid any.

♻️ Example adjustment
-    mockedUseLiveKitStore.mockReturnValue({
+    mockedUseLiveKitStore.mockReturnValue({
       isConnected: false,
       currentRoom: null,
       toggleMicrophone: mockToggleMicrophone,
       setMicrophoneEnabled: mockSetMicrophoneEnabled,
       startHeadsetButtonMonitoring: mockStartHeadsetButtonMonitoring,
       stopHeadsetButtonMonitoring: mockStopHeadsetButtonMonitoring,
-    } as any);
+    } as ReturnType<typeof useLiveKitStore>);
 
-    mockedUseBluetoothAudioStore.mockReturnValue({
+    mockedUseBluetoothAudioStore.mockReturnValue({
       isHeadsetButtonMonitoring: false,
       headsetButtonConfig: {
         pttMode: 'toggle',
         soundFeedback: true,
       },
       lastButtonAction: null,
       setHeadsetButtonConfig: mockSetHeadsetButtonConfig,
-    } as any);
+    } as ReturnType<typeof useBluetoothAudioStore>);
src/services/headset-button.service.ts (1)

19-37: Reduce any usage for dynamic modules and event payloads.
any masks invalid fields and weakens editor help; a small event interface + typed dynamic imports keeps flexibility while improving safety.

♻️ Possible typing cleanup
-// eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-let liveKitStoreModule: any = null;
+type LiveKitStoreModule = typeof import('@/stores/app/livekit-store');
+type AudioService = typeof import('@/services/audio.service')['audioService'];
+type HeadsetButtonNativeEvent = {
+  type?: string | number;
+  keyCode?: number;
+  command?: string;
+  source?: string;
+  deviceName?: string;
+  newRoute?: string;
+};
+let liveKitStoreModule: LiveKitStoreModule | null = null;
@@
-let audioService: any;
+let audioService: Partial<AudioService> = {};
@@
-  private handleHeadsetButtonEvent(event: any): void {
+  private handleHeadsetButtonEvent(event: HeadsetButtonNativeEvent): void {
@@
-  private handleRemoteControlEvent(event: any): void {
+  private handleRemoteControlEvent(event: HeadsetButtonNativeEvent): void {
@@
-  private handleMediaButtonEvent(event: any): void {
+  private handleMediaButtonEvent(event: HeadsetButtonNativeEvent): void {
@@
-  private handleAudioRouteChange(event: any): void {
+  private handleAudioRouteChange(event: HeadsetButtonNativeEvent): void {
@@
-  private handleHeadsetConnectionChange(event: any): void {
+  private handleHeadsetConnectionChange(event: HeadsetButtonNativeEvent): void {

As per coding guidelines, avoid any in TypeScript.

Also applies to: 198-283

- Handle errors gracefully and provide user feedback.
- Implement proper offline support.
- Ensure the user interface is intuitive and user-friendly and works seamlessly across different devices and screen sizes.
- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hyphenate and capitalize “Expo‑managed.”
Minor wording polish for readability.

✏️ Suggested edit
- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities.
+ This is an Expo-managed project that uses prebuild; do not make native code changes outside of Expo prebuild capabilities.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities.
- This is an Expo-managed project that uses prebuild; do not make native code changes outside of Expo prebuild capabilities.
🧰 Tools
🪛 LanguageTool

[grammar] ~51-~51: Use a hyphen to join words.
Context: ...ices and screen sizes. - This is an expo managed project that uses prebuild, do n...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
In @.github/copilot-instructions.md at line 51, Update the sentence "This is an
expo managed project that uses prebuild, do not make native code changes outside
of expo prebuild capabilities." to use the hyphenated and capitalized form
"Expo‑managed" and adjust capitalization as needed so it reads naturally (e.g.,
"This is an Expo‑managed project that uses prebuild; do not make native code
changes outside of Expo prebuild capabilities.")—locate and replace the exact
string in the document.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/lib/hooks/__tests__/use-headset-button-ptt.test.ts`:
- Around line 498-503: The test uses an invalid PttMode value ('push') in the
config object; update the test in use-headset-button-ptt.test.ts so the
config.pttMode uses a valid PttMode string (one of 'toggle', 'push_to_talk', or
'disabled') so TypeScript typing aligns with the PttMode union and the test
continues to assert expected behavior for the hook under test (replace the
pttMode value in the config object used in the 'should expose current config'
spec).

In `@src/services/headset-button.service.ts`:
- Around line 262-273: handleAudioRouteChange currently only sets bluetooth
routing to true (via bluetoothStore.setAudioRoutingActive(true)) when
event.newRoute contains "Bluetooth" or "AirPods", leaving the store stuck true
when the route changes away; update the method to setAudioRoutingActive(false)
when event?.newRoute does not include those strings or is missing, i.e.,
determine a boolean isBluetoothRoute = !!event?.newRoute &&
(event.newRoute.includes('Bluetooth') || event.newRoute.includes('AirPods')) and
call bluetoothStore.setAudioRoutingActive(isBluetoothRoute) so the store is
correctly toggled both on and off.
- Around line 19-37: The lazy imports use `any`, losing type-safety; replace
them with type-only imports and narrow types: add a type-only import for the
exported hook (e.g., useLiveKitStore's type) and declare liveKitStoreModule so
getLiveKitStore returns that hook's typed signature (reference: getLiveKitStore,
liveKitStoreModule, useLiveKitStore); similarly import the AudioService type
only and type the dynamic audioService as AudioService | Record<string, never>,
ensuring it exposes playStartTransmittingSound and playStopTransmittingSound
(reference: audioService, AudioService, playStartTransmittingSound,
playStopTransmittingSound) while keeping the runtime require/try-catch to avoid
circular imports.
♻️ Duplicate comments (2)
src/services/headset-button.service.ts (1)

413-447: Capture clickCount before awaiting to avoid lost presses.

buttonPressCount is reset only after the async action completes. If a new press comes in while executeButtonAction is in flight, the finally block can wipe the new sequence. Capture and clear before the await.

🐛 Suggested fix
     // Set timer to process the final action
     this.buttonPressTimer = setTimeout(() => {
-      (async () => {
-        try {
-          await this.executeButtonAction(event, this.buttonPressCount);
-        } catch (error) {
-          console.error('Error executing button action:', error);
-        } finally {
-          this.buttonPressCount = 0;
-        }
-      })();
+      const clickCount = this.buttonPressCount;
+      this.buttonPressCount = 0;
+      void this.executeButtonAction(event, clickCount).catch((error) => {
+        console.error('Error executing button action:', error);
+      });
     }, this.DOUBLE_CLICK_THRESHOLD);
src/components/calls/call-images-modal.tsx (1)

156-205: Normalize uploaded filenames to the actual encoded format.

selectedImageInfo.filename can retain the original asset extension (e.g., .heic/.jpg), while native manipulation re-encodes to PNG and the fallback path switches to JPEG. That can reintroduce extension/MIME mismatches during upload. Consider deriving the upload filename from the final ImageManipulator format (and, if using a data-URI fallback on web, the URI’s mime type) right before uploadCallImage.

🛠️ Suggested fix
@@
-    try {
-      let base64Image: string;
+    try {
+      let base64Image: string;
+      let outputFormat = Platform.OS === 'web' ? ImageManipulator.SaveFormat.JPEG : ImageManipulator.SaveFormat.PNG;
@@
-          const manipulatedImage = await ImageManipulator.manipulateAsync(selectedImageInfo.uri, [{ resize: { width: 1024 } }], {
-            compress: 0.8,
-            format: ImageManipulator.SaveFormat.JPEG,
-            base64: true, // Get base64 directly to avoid FileSystem issues on web
-          });
+          const manipulatedImage = await ImageManipulator.manipulateAsync(selectedImageInfo.uri, [{ resize: { width: 1024 } }], {
+            compress: 0.8,
+            format: outputFormat,
+            base64: true, // Get base64 directly to avoid FileSystem issues on web
+          });
@@
-          manipulatedImage = await ImageManipulator.manipulateAsync(selectedImageInfo.uri, [{ resize: { width: 1024 } }], {
-            compress: 0.8,
-            format: ImageManipulator.SaveFormat.PNG,
-            base64: true, // Get base64 directly
-          });
+          manipulatedImage = await ImageManipulator.manipulateAsync(selectedImageInfo.uri, [{ resize: { width: 1024 } }], {
+            compress: 0.8,
+            format: outputFormat,
+            base64: true, // Get base64 directly
+          });
         } catch (manipulateError) {
           console.error('Error manipulating image:', manipulateError);
           // Try without resize as fallback for problematic images
+          outputFormat = ImageManipulator.SaveFormat.JPEG;
           manipulatedImage = await ImageManipulator.manipulateAsync(
             selectedImageInfo.uri,
             [], // No transformations
             {
               compress: 0.8,
-              format: ImageManipulator.SaveFormat.JPEG,
+              format: outputFormat,
               base64: true,
             }
           );
         }
@@
-      await uploadCallImage(
+      const normalizeFilename = (name: string, extension: string) =>
+        name.includes('.') ? name.replace(/\.[^.]+$/, `.${extension}`) : `${name}.${extension}`;
+      const uploadFilename = normalizeFilename(
+        selectedImageInfo.filename,
+        outputFormat === ImageManipulator.SaveFormat.PNG ? 'png' : 'jpg'
+      );
+
+      await uploadCallImage(
         callId,
         useAuthStore.getState().userId!,
         newImageNote || '', // Use note for the note field
-        selectedImageInfo.filename, // Use filename for the name field
+        uploadFilename, // Match filename to encoded format
         currentLatitude, // Current latitude
         currentLongitude, // Current longitude
         base64Image
       );

Also applies to: 308-379

🧹 Nitpick comments (3)
src/components/contacts/contact-details-sheet.tsx (1)

145-194: Consider dynamic height for variable HTML content.

The HtmlContentField component uses a fixed minHeight of 60px with scrollEnabled={false}. For longer HTML content (e.g., detailed descriptions or notes), this may clip the content without any way for users to view the rest.

Consider either:

  1. Enabling scroll for the WebView when content exceeds the container
  2. Implementing dynamic height measurement using onContentSizeChange or injectedJavaScript to measure content height
♻️ Option 1: Enable scrolling for overflow
       <View className="min-h-[60px] overflow-hidden rounded-lg">
         <WebView
           style={[styles.htmlContent, { backgroundColor: bgColor }]}
           originWhitelist={['about:']}
-          scrollEnabled={false}
+          scrollEnabled={true}
           showsVerticalScrollIndicator={false}
♻️ Option 2: Dynamic height with content measurement
const HtmlContentField: React.FC<HtmlContentFieldProps> = ({ label, value }) => {
  const { colorScheme } = useColorScheme();
  const [webViewHeight, setWebViewHeight] = useState(60);

  // ... existing code ...

  return (
    <VStack space="xs" className="py-2">
      <Text className="text-sm text-gray-500 dark:text-gray-400">{label}</Text>
      <View style={{ minHeight: webViewHeight }} className="overflow-hidden rounded-lg">
        <WebView
          style={[styles.htmlContent, { backgroundColor: bgColor, height: webViewHeight }]}
          // ... other props ...
          onMessage={(event) => {
            const height = parseInt(event.nativeEvent.data, 10);
            if (height > 0) setWebViewHeight(height);
          }}
          injectedJavaScript={`
            window.ReactNativeWebView.postMessage(
              document.body.scrollHeight.toString()
            );
            true;
          `}
          javaScriptEnabled={true} // Required for height measurement
          // ... rest of props ...
        />
      </View>
    </VStack>
  );
};
src/components/calls/call-images-modal.tsx (2)

395-414: Replace any in the error callback.

Use unknown (or a specific error type) to keep type safety and avoid propagating any.

♻️ Suggested typing
-  const handleImageError = useCallback((itemId: string, error: any) => {
-    console.error(`Image failed to load for ${itemId}:`, error);
+  const handleImageError = useCallback((itemId: string, error: unknown) => {
+    const message = error instanceof Error ? error.message : String(error);
+    console.error(`Image failed to load for ${itemId}:`, message);
     setImageErrors((prev) => new Set([...prev, itemId]));
   }, []);
As per coding guidelines, avoid `any`.

456-475: Avoid inline handlers in renderItem to reduce re-renders.

Inline lambdas for onPress/onLoad/onError allocate per item; extracting a memoized child with stable callbacks keeps FlashList perf predictable.

♻️ Suggested refactor (usage)
-        <TouchableOpacity
-          onPress={() => handleImagePress(imageSource, item.Name)}
-          testID={`image-${item.Id}-touchable`}
-          activeOpacity={0.7}
-          style={{ width: '100%' }}
-          delayPressIn={0}
-          delayPressOut={0}
-        >
-          <Image
-            key={`${item.Id}-${index}`}
-            source={imageSource}
-            style={styles.galleryImage}
-            contentFit="contain"
-            transition={200}
-            pointerEvents="none"
-            cachePolicy="memory-disk"
-            recyclingKey={item.Id}
-            onError={() => handleImageLoadError(item.Id)}
-            onLoad={() => handleImageLoadSuccess(item.Id)}
-          />
-        </TouchableOpacity>
+        <CallImagePressable
+          itemId={item.Id}
+          itemName={item.Name}
+          imageSource={imageSource}
+          testID={`image-${item.Id}-touchable`}
+          onPressImage={handleImagePress}
+          onErrorImage={handleImageLoadError}
+          onLoadImage={handleImageLoadSuccess}
+        />

Example component (place near the other helpers):

interface CallImagePressableProps {
  itemId: string;
  itemName?: string;
  imageSource: { uri: string };
  testID?: string;
  onPressImage: (source: { uri: string }, name?: string) => void;
  onLoadImage: (itemId: string) => void;
  onErrorImage: (itemId: string) => void;
}

const CallImagePressable: React.FC<CallImagePressableProps> = React.memo(
  ({ itemId, itemName, imageSource, testID, onPressImage, onLoadImage, onErrorImage }) => {
    const handlePress = useCallback(() => onPressImage(imageSource, itemName), [onPressImage, imageSource, itemName]);
    const handleLoad = useCallback(() => onLoadImage(itemId), [onLoadImage, itemId]);
    const handleError = useCallback(() => onErrorImage(itemId), [onErrorImage, itemId]);

    return (
      <TouchableOpacity
        onPress={handlePress}
        testID={testID}
        activeOpacity={0.7}
        style={{ width: '100%' }}
        delayPressIn={0}
        delayPressOut={0}
      >
        <Image
          source={imageSource}
          style={styles.galleryImage}
          contentFit="contain"
          transition={200}
          pointerEvents="none"
          cachePolicy="memory-disk"
          recyclingKey={itemId}
          onError={handleError}
          onLoad={handleLoad}
        />
      </TouchableOpacity>
    );
  }
);

Based on learnings, avoid anonymous handlers in renderItem/event callbacks.

Comment on lines +498 to +503
it('should expose current config', () => {
const config = {
pttMode: 'push' as const,
soundFeedback: false,
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a valid PttMode value in the test config.

'push' is not part of the PttMode union ('toggle' | 'push_to_talk' | 'disabled'). This weakens the type-signal of the test.

✅ Suggested fix
       const config = {
-        pttMode: 'push' as const,
+        pttMode: 'push_to_talk' as const,
         soundFeedback: false,
       };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should expose current config', () => {
const config = {
pttMode: 'push' as const,
soundFeedback: false,
};
it('should expose current config', () => {
const config = {
pttMode: 'push_to_talk' as const,
soundFeedback: false,
};
🤖 Prompt for AI Agents
In `@src/lib/hooks/__tests__/use-headset-button-ptt.test.ts` around lines 498 -
503, The test uses an invalid PttMode value ('push') in the config object;
update the test in use-headset-button-ptt.test.ts so the config.pttMode uses a
valid PttMode string (one of 'toggle', 'push_to_talk', or 'disabled') so
TypeScript typing aligns with the PttMode union and the test continues to assert
expected behavior for the hook under test (replace the pttMode value in the
config object used in the 'should expose current config' spec).

Comment on lines +19 to +37
// Import livekit store lazily to avoid circular dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let liveKitStoreModule: any = null;
const getLiveKitStore = () => {
if (!liveKitStoreModule) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
liveKitStoreModule = require('@/stores/app/livekit-store');
}
return liveKitStoreModule.useLiveKitStore;
};

// Import audioService dynamically to avoid expo module import errors in tests
let audioService: any;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
audioService = require('@/services/audio.service').audioService;
} catch {
audioService = {};
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find and display the headset-button.service.ts file
fd -t f "headset-button.service.ts"

Repository: Resgrid/Responder

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Search for any explicit `any` types in the file
rg -n "\bany\b" src/services/headset-button.service.ts

Repository: Resgrid/Responder

Length of output: 631


🏁 Script executed:

#!/bin/bash
# Read the full file to understand context and all `any` usages
cat -n src/services/headset-button.service.ts | head -60

Repository: Resgrid/Responder

Length of output: 2600


🏁 Script executed:

#!/bin/bash
# Check if livekit-store exists and what it exports
fd -t f "livekit-store.ts"

Repository: Resgrid/Responder

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Read livekit-store to verify useLiveKitStore export
cat -n src/stores/app/livekit-store.ts | head -30

Repository: Resgrid/Responder

Length of output: 1941


🏁 Script executed:

#!/bin/bash
# Check if audio.service exists and what it exports
fd -t f "audio.service.ts"

Repository: Resgrid/Responder

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Read audio.service to verify audioService export structure
cat -n src/services/audio.service.ts | head -40

Repository: Resgrid/Responder

Length of output: 1554


🏁 Script executed:

#!/bin/bash
# Search for useLiveKitStore export in livekit-store
rg -n "useLiveKitStore\|export.*create" src/stores/app/livekit-store.ts | head -20

Repository: Resgrid/Responder

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check the end of livekit-store to see exports
tail -50 src/stores/app/livekit-store.ts | cat -n

Repository: Resgrid/Responder

Length of output: 1691


🏁 Script executed:

#!/bin/bash
# Check audio.service exports at the end of file
tail -20 src/services/audio.service.ts | cat -n

Repository: Resgrid/Responder

Length of output: 690


🏁 Script executed:

#!/bin/bash
# Search for the export statement in livekit-store more thoroughly
rg -A 5 "export" src/stores/app/livekit-store.ts | tail -40

Repository: Resgrid/Responder

Length of output: 240


🏁 Script executed:

#!/bin/bash
# Get line count and check the very end of livekit-store.ts
wc -l src/stores/app/livekit-store.ts
tail -10 src/stores/app/livekit-store.ts | cat -n

Repository: Resgrid/Responder

Length of output: 388


🏁 Script executed:

#!/antml/bin/bash
# Search for playStartTransmittingSound and playStopTransmittingSound in audio.service
rg -n "playStartTransmittingSound|playStopTransmittingSound" src/services/audio.service.ts

Repository: Resgrid/Responder

Length of output: 175


🏁 Script executed:

#!/bin/bash
# Get the full picture of what getLiveKitStore returns (verify it's being used correctly in the file)
rg -A 3 "getLiveKitStore()" src/services/headset-button.service.ts | head -20

Repository: Resgrid/Responder

Length of output: 895


🏁 Script executed:

#!/bin/bash
# Verify audioService method usage in headset-button.service.ts
rg -n "audioService\." src/services/headset-button.service.ts

Repository: Resgrid/Responder

Length of output: 185


Avoid any in lazy imports; add minimal typings.

any erodes type-safety for getLiveKitStore and audioService. Since livekit-store.ts exports useLiveKitStore from zustand and audio.service.ts exports an AudioService instance with playStartTransmittingSound() and playStopTransmittingSound() methods, you can safely add type-only imports and interfaces without triggering circular dependencies.

♻️ Suggested update
 import { AppState, DeviceEventEmitter, NativeEventEmitter, NativeModules, Platform } from 'react-native';
+import type { useLiveKitStore } from '@/stores/app/livekit-store';

 // Import livekit store lazily to avoid circular dependency
-// eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-let liveKitStoreModule: any = null;
+type LiveKitStoreModule = { useLiveKitStore: typeof useLiveKitStore };
+let liveKitStoreModule: LiveKitStoreModule | null = null;
 const getLiveKitStore = () => {
   if (!liveKitStoreModule) {
     // eslint-disable-next-line `@typescript-eslint/no-var-requires`
-    liveKitStoreModule = require('@/stores/app/livekit-store');
+    liveKitStoreModule = require('@/stores/app/livekit-store') as LiveKitStoreModule;
   }
   return liveKitStoreModule.useLiveKitStore;
 };

 // Import audioService dynamically to avoid expo module import errors in tests
-let audioService: any;
+interface AudioService {
+  playStartTransmittingSound?: () => Promise<void>;
+  playStopTransmittingSound?: () => Promise<void>;
+}
+let audioService: AudioService = {};
 try {
   // eslint-disable-next-line `@typescript-eslint/no-var-requires`
-  audioService = require('@/services/audio.service').audioService;
+  audioService = require('@/services/audio.service').audioService as AudioService;
 } catch {
   audioService = {};
 }

Type-only imports are erased at runtime and do not create circular dependencies. Align with coding guidelines to avoid any and use precise types.

🤖 Prompt for AI Agents
In `@src/services/headset-button.service.ts` around lines 19 - 37, The lazy
imports use `any`, losing type-safety; replace them with type-only imports and
narrow types: add a type-only import for the exported hook (e.g.,
useLiveKitStore's type) and declare liveKitStoreModule so getLiveKitStore
returns that hook's typed signature (reference: getLiveKitStore,
liveKitStoreModule, useLiveKitStore); similarly import the AudioService type
only and type the dynamic audioService as AudioService | Record<string, never>,
ensuring it exposes playStartTransmittingSound and playStopTransmittingSound
(reference: audioService, AudioService, playStartTransmittingSound,
playStopTransmittingSound) while keeping the runtime require/try-catch to avoid
circular imports.

Comment on lines +262 to +273
private handleAudioRouteChange(event: any): void {
logger.debug({
message: 'Audio route changed',
context: { event },
});

// Update bluetooth audio store with new route info if needed
const bluetoothStore = useBluetoothAudioStore.getState();
if (event?.newRoute?.includes('Bluetooth') || event?.newRoute?.includes('AirPods')) {
bluetoothStore.setAudioRoutingActive(true);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid leaving bluetooth routing state stuck on true.

setAudioRoutingActive(true) is called for Bluetooth/AirPods, but there’s no path to set it back to false when the route changes away. This can leave stale state in the store.

💡 Suggested tweak
-    if (event?.newRoute?.includes('Bluetooth') || event?.newRoute?.includes('AirPods')) {
-      bluetoothStore.setAudioRoutingActive(true);
-    }
+    const isBluetoothRoute =
+      event?.newRoute?.includes('Bluetooth') || event?.newRoute?.includes('AirPods');
+    bluetoothStore.setAudioRoutingActive(Boolean(isBluetoothRoute));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private handleAudioRouteChange(event: any): void {
logger.debug({
message: 'Audio route changed',
context: { event },
});
// Update bluetooth audio store with new route info if needed
const bluetoothStore = useBluetoothAudioStore.getState();
if (event?.newRoute?.includes('Bluetooth') || event?.newRoute?.includes('AirPods')) {
bluetoothStore.setAudioRoutingActive(true);
}
}
private handleAudioRouteChange(event: any): void {
logger.debug({
message: 'Audio route changed',
context: { event },
});
// Update bluetooth audio store with new route info if needed
const bluetoothStore = useBluetoothAudioStore.getState();
const isBluetoothRoute =
event?.newRoute?.includes('Bluetooth') || event?.newRoute?.includes('AirPods');
bluetoothStore.setAudioRoutingActive(Boolean(isBluetoothRoute));
}
🤖 Prompt for AI Agents
In `@src/services/headset-button.service.ts` around lines 262 - 273,
handleAudioRouteChange currently only sets bluetooth routing to true (via
bluetoothStore.setAudioRoutingActive(true)) when event.newRoute contains
"Bluetooth" or "AirPods", leaving the store stuck true when the route changes
away; update the method to setAudioRoutingActive(false) when event?.newRoute
does not include those strings or is missing, i.e., determine a boolean
isBluetoothRoute = !!event?.newRoute && (event.newRoute.includes('Bluetooth') ||
event.newRoute.includes('AirPods')) and call
bluetoothStore.setAudioRoutingActive(isBluetoothRoute) so the store is correctly
toggled both on and off.

Comment on lines +463 to +495
/**
* Execute the appropriate action based on button event and click count
*/
private async executeButtonAction(event: HeadsetButtonEvent, clickCount: number): Promise<void> {
const liveKitStore = getLiveKitStore().getState();

// Only process if we have an active LiveKit connection
if (!liveKitStore.currentRoom || !liveKitStore.isConnected) {
logger.debug({
message: 'No active LiveKit room, ignoring headset button',
context: { event, clickCount },
});
return;
}

// Handle based on button type and click count
if ((event.type === 'play_pause' || event.type === 'hook') && clickCount === 1) {
// Single click - toggle mute based on config
if (this.config.playPauseAction === 'toggle_mute') {
await this.toggleMicrophone();
}
} else if ((event.type === 'play_pause' || event.type === 'hook') && clickCount === 2) {
// Double click
if (this.config.doubleClickAction === 'toggle_mute') {
await this.toggleMicrophone();
}
}

logger.info({
message: 'Headset button action executed',
context: { event, clickCount, action: 'toggle_mute' },
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

longPressAction and PTT modes are currently ignored.

HeadsetButtonConfig.longPressAction and pttMode are exposed, but executeButtonAction only handles single/double click toggles. Also, LONG_PRESS_THRESHOLD, longPressTimer, and isButtonHeld are unused. If the UI exposes long-press or push‑to‑talk, this will never fire.

Please either implement the long‑press / PTT behavior or remove those options from the config to avoid misleading behavior.

@ucswift
Copy link
Member Author

ucswift commented Jan 24, 2026

Approve

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is approved.

@ucswift ucswift merged commit 5398932 into master Jan 24, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant