Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/api/calls/callFiles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResponse } from 'axios';
import { Platform } from 'react-native';

import { createApiEndpoint } from '@/api/common/client';
import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult';
Expand Down Expand Up @@ -77,16 +78,20 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption
}
};

// Utility function to save a blob as a file
// Utility function to save a blob as a file (web only)
export const saveBlobAsFile = (blob: Blob, fileName: string): void => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();

// Clean up
window.URL.revokeObjectURL(url);
if (Platform.OS === 'web') {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();

// Clean up
window.URL.revokeObjectURL(url);
} else {
console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.');
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export const getFiles = async (callId: string, includeData: boolean, type: number) => {
Expand Down
7 changes: 1 addition & 6 deletions src/components/livekit/livekit-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ActivityIndicator, ScrollView, StyleSheet, TouchableOpacity, View } fro
import { useAnalytics } from '@/hooks/use-analytics';
import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData';
import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
import { applyAudioRouting, requestAndroidPhonePermissions, useLiveKitStore } from '@/stores/app/livekit-store';
import { applyAudioRouting, useLiveKitStore } from '@/stores/app/livekit-store';

import { AudioDeviceSelection } from '../settings/audio-device-selection';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet';
Expand Down Expand Up @@ -86,11 +86,6 @@ export const LiveKitBottomSheet = () => {
// If we're showing the sheet, make sure we have the latest rooms
if (isBottomSheetVisible && currentView === BottomSheetView.ROOM_SELECT) {
fetchVoiceSettings();
// Pre-warm Android phone-state permissions (READ_PHONE_STATE / READ_PHONE_NUMBERS)
// while the user is browsing the room list. The system dialog, if any, appears
// here in a clean window instead of blocking the Join flow later. On subsequent
// opens this is an instant no-op (permissions already granted).
void requestAndroidPhonePermissions();
}
}, [isBottomSheetVisible, currentView, fetchVoiceSettings]);

Expand Down
49 changes: 49 additions & 0 deletions src/components/maps/mapbox.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Native (iOS/Android) implementation of map components using @rnmapbox/maps
* Metro bundler resolves this file on native platforms via the .native extension.
*/
import Mapbox from '@rnmapbox/maps';

// Re-export all Mapbox components for native platforms
export const MapView = Mapbox.MapView;
export const Camera = Mapbox.Camera;
export const PointAnnotation = Mapbox.PointAnnotation;
export const UserLocation = Mapbox.UserLocation;
export const MarkerView = Mapbox.MarkerView;
export const ShapeSource = Mapbox.ShapeSource;
export const SymbolLayer = Mapbox.SymbolLayer;
export const CircleLayer = Mapbox.CircleLayer;
export const LineLayer = Mapbox.LineLayer;
export const FillLayer = Mapbox.FillLayer;
export const Images = Mapbox.Images;
export const Callout = Mapbox.Callout;

// Export style URL constants
export const StyleURL = Mapbox.StyleURL;

// Export UserTrackingMode
export const UserTrackingMode = Mapbox.UserTrackingMode;

// Export setAccessToken
export const setAccessToken = Mapbox.setAccessToken;

// Default export matching Mapbox structure with all properties
const MapboxExports = {
MapView: Mapbox.MapView,
Camera: Mapbox.Camera,
PointAnnotation: Mapbox.PointAnnotation,
UserLocation: Mapbox.UserLocation,
MarkerView: Mapbox.MarkerView,
ShapeSource: Mapbox.ShapeSource,
SymbolLayer: Mapbox.SymbolLayer,
CircleLayer: Mapbox.CircleLayer,
LineLayer: Mapbox.LineLayer,
FillLayer: Mapbox.FillLayer,
Images: Mapbox.Images,
Callout: Mapbox.Callout,
StyleURL: Mapbox.StyleURL,
UserTrackingMode: Mapbox.UserTrackingMode,
setAccessToken: Mapbox.setAccessToken,
};

export default MapboxExports;
61 changes: 6 additions & 55 deletions src/components/maps/mapbox.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,8 @@
/**
* Platform-aware map components
* Automatically selects native (@rnmapbox/maps) or web (mapbox-gl) implementation
* TypeScript type resolution shim for platform-specific Mapbox implementations.
* Metro resolves mapbox.native.ts on iOS/Android and mapbox.web.ts on web,
* but TypeScript needs a base file to satisfy module resolution.
* This file re-exports from the native implementation so types are available.
*/
import { Platform } from 'react-native';

import * as MapboxNative from './map-view.native';
import * as MapboxWeb from './map-view.web';

// Import the platform-specific implementation
// Metro bundler will resolve to the correct file based on platform
const MapboxImpl = Platform.OS === 'web' ? MapboxWeb.default : MapboxNative.default;

// Re-export all components
export const MapView = MapboxImpl.MapView || MapboxImpl;
export const Camera = Platform.OS === 'web' ? MapboxWeb.Camera : MapboxNative.Camera;
export const PointAnnotation = Platform.OS === 'web' ? MapboxWeb.PointAnnotation : MapboxNative.PointAnnotation;
export const UserLocation = Platform.OS === 'web' ? MapboxWeb.UserLocation : MapboxNative.UserLocation;
export const MarkerView = Platform.OS === 'web' ? MapboxWeb.MarkerView : MapboxNative.MarkerView;
export const ShapeSource = Platform.OS === 'web' ? MapboxWeb.ShapeSource : MapboxNative.ShapeSource;
export const SymbolLayer = Platform.OS === 'web' ? MapboxWeb.SymbolLayer : MapboxNative.SymbolLayer;
export const CircleLayer = Platform.OS === 'web' ? MapboxWeb.CircleLayer : MapboxNative.CircleLayer;
export const LineLayer = Platform.OS === 'web' ? MapboxWeb.LineLayer : MapboxNative.LineLayer;
export const FillLayer = Platform.OS === 'web' ? MapboxWeb.FillLayer : MapboxNative.FillLayer;
export const Images = Platform.OS === 'web' ? MapboxWeb.Images : MapboxNative.Images;
export const Callout = Platform.OS === 'web' ? MapboxWeb.Callout : MapboxNative.Callout;

// Export style URL constants
export const StyleURL = Platform.OS === 'web' ? MapboxWeb.StyleURL : MapboxNative.StyleURL;

// Export UserTrackingMode
export const UserTrackingMode = Platform.OS === 'web' ? MapboxWeb.UserTrackingMode : MapboxNative.UserTrackingMode;

// Export setAccessToken
export const setAccessToken = Platform.OS === 'web' ? MapboxWeb.setAccessToken : MapboxNative.setAccessToken;

// Default export matching Mapbox structure with all properties
const Mapbox = {
...MapboxImpl,
MapView: MapView,
Camera: Camera,
PointAnnotation: PointAnnotation,
UserLocation: UserLocation,
MarkerView: MarkerView,
ShapeSource: ShapeSource,
SymbolLayer: SymbolLayer,
CircleLayer: CircleLayer,
LineLayer: LineLayer,
FillLayer: FillLayer,
Images: Images,
Callout: Callout,
StyleURL: StyleURL,
UserTrackingMode: UserTrackingMode,
setAccessToken: setAccessToken,
};

export default Mapbox;
export { Callout, Camera, CircleLayer, FillLayer, Images, LineLayer, MapView, MarkerView, PointAnnotation, setAccessToken, ShapeSource, StyleURL, SymbolLayer, UserLocation, UserTrackingMode } from './mapbox.native';
export { default } from './mapbox.native';
50 changes: 50 additions & 0 deletions src/components/maps/mapbox.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Web/Electron implementation of map components using mapbox-gl
* Metro bundler resolves this file on web platforms via the .web extension.
*/
import * as MapboxWeb from './map-view.web';

// Re-export all components from the web implementation
export const MapView = MapboxWeb.MapView;
export const Camera = MapboxWeb.Camera;
export const PointAnnotation = MapboxWeb.PointAnnotation;
export const UserLocation = MapboxWeb.UserLocation;
export const MarkerView = MapboxWeb.MarkerView;
export const ShapeSource = MapboxWeb.ShapeSource;
export const SymbolLayer = MapboxWeb.SymbolLayer;
export const CircleLayer = MapboxWeb.CircleLayer;
export const LineLayer = MapboxWeb.LineLayer;
export const FillLayer = MapboxWeb.FillLayer;
export const Images = MapboxWeb.Images;
export const Callout = MapboxWeb.Callout;

// Export style URL constants
export const StyleURL = MapboxWeb.StyleURL;

// Export UserTrackingMode
export const UserTrackingMode = MapboxWeb.UserTrackingMode;

// Export setAccessToken
export const setAccessToken = MapboxWeb.setAccessToken;

// Default export matching Mapbox structure with all properties
const MapboxExports = {
...MapboxWeb.default,
MapView,
Camera,
PointAnnotation,
UserLocation,
MarkerView,
ShapeSource,
SymbolLayer,
CircleLayer,
LineLayer,
FillLayer,
Images,
Callout,
StyleURL,
UserTrackingMode,
setAccessToken,
};

export default MapboxExports;
3 changes: 1 addition & 2 deletions src/components/maps/static-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ const StaticMap: React.FC<StaticMapProps> = ({ latitude, longitude, address, zoo
compassEnabled={true}
zoomEnabled={true}
rotateEnabled={true}
initialCenter={[longitude, latitude]}
initialZoom={zoom}
{...(Platform.OS === 'web' ? { initialCenter: [longitude, latitude] as [number, number], initialZoom: zoom } : {})}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
>
<Mapbox.Camera zoomLevel={zoom} centerCoordinate={[longitude, latitude]} animationMode="flyTo" animationDuration={1000} />
{/* Marker pin for the location */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ jest.mock('@/stores/app/location-store', () => ({
jest.mock('@/stores/app/livekit-store', () => ({
useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
}) : {
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ jest.mock('@/stores/app/location-store', () => ({
jest.mock('@/stores/app/livekit-store', () => ({
useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
}) : {
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
Expand Down Expand Up @@ -62,6 +64,7 @@ const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction<typeo
describe('SidebarUnitCard', () => {
const mockSetMapLocked = jest.fn();
const mockSetIsBottomSheetVisible = jest.fn();
const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true);
const mockSetAudioStreamBottomSheetVisible = jest.fn();

const defaultProps = {
Expand Down Expand Up @@ -90,11 +93,13 @@ describe('SidebarUnitCard', () => {

mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
ensureMicrophonePermission: mockEnsureMicrophonePermission,
currentRoomInfo: null,
isConnected: false,
isTalking: false,
}) : {
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
ensureMicrophonePermission: mockEnsureMicrophonePermission,
currentRoomInfo: null,
isConnected: false,
isTalking: false,
Expand Down Expand Up @@ -166,12 +171,13 @@ describe('SidebarUnitCard', () => {
});
});

it('opens LiveKit when call button is pressed', () => {
it('opens LiveKit when call button is pressed', async () => {
render(<SidebarUnitCard {...defaultProps} />);

const callButton = screen.getByTestId('call-button');
fireEvent.press(callButton);
await fireEvent.press(callButton);

expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
});
});
Expand Down
12 changes: 10 additions & 2 deletions src/components/sidebar/__tests__/unit-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ describe('SidebarUnitCard', () => {
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(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
}) : {
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: null,
isConnected: false,
isTalking: false,
Expand Down Expand Up @@ -150,15 +152,18 @@ describe('SidebarUnitCard', () => {
expect(mockSetAudioStreamBottomSheetVisible).toHaveBeenCalledWith(true);
});

it('handles call button press', () => {
it('handles call button press', async () => {
const mockSetIsBottomSheetVisible = jest.fn();
const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true);
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
ensureMicrophonePermission: mockEnsureMicrophonePermission,
currentRoomInfo: null,
isConnected: false,
isTalking: false,
}) : {
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
ensureMicrophonePermission: mockEnsureMicrophonePermission,
currentRoomInfo: null,
isConnected: false,
isTalking: false,
Expand All @@ -167,20 +172,23 @@ describe('SidebarUnitCard', () => {
render(<SidebarUnitCard {...defaultProps} />);

const callButton = screen.getByTestId('call-button');
fireEvent.press(callButton);
await fireEvent.press(callButton);

expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
});

it('shows room status when connected', () => {
const mockRoomInfo = { Name: 'Emergency Call Room' };
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: mockRoomInfo as any,
isConnected: true,
isTalking: false,
}) : {
setIsBottomSheetVisible: jest.fn(),
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
currentRoomInfo: mockRoomInfo as any,
isConnected: true,
isTalking: false,
Expand Down
7 changes: 6 additions & 1 deletion src/components/sidebar/unit-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ItemProps = {
export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => {
const activeUnit = useCoreStore((state) => state.activeUnit);
const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible);
const ensureMicrophonePermission = useLiveKitStore((state) => state.ensureMicrophonePermission);
const currentRoomInfo = useLiveKitStore((state) => state.currentRoomInfo);
const isConnected = useLiveKitStore((state) => state.isConnected);
const isTalking = useLiveKitStore((state) => state.isTalking);
Expand All @@ -35,7 +36,11 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn
const displayType = activeUnit?.Type ?? defaultUnitType;
const displayGroup = activeUnit?.GroupName ?? defaultUnitGroup;

const handleOpenLiveKit = () => {
const handleOpenLiveKit = async () => {
// Request microphone permission before the Actionsheet (Modal) opens.
// On Android, system permission dialogs are hidden behind React Native
// Modals, so we must request while no Modal is on screen.
await ensureMicrophonePermission();
setIsBottomSheetVisible(true);
};

Expand Down
Loading