snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import type { MediaSignalingSession } from '@rocket.chat/media-signaling';
import type { RefObject } from 'react';
import { createContext, useContext } from 'react';
import type { PeerAutocompleteOptions } from '../components';
import type { PeerInfo } from './definitions';
export type AvailableViews = 'room' | 'popout' | 'widget';
type RegisterView = (view: AvailableViews) => void;
type UnregisterView = (view: AvailableViews) => void;
type WidgetVisibility = 'open' | 'closed';
export type MediaCallInstanceContextValue = {
instance: MediaSignalingSession | undefined;
audioElement: RefObject<HTMLAudioElement | null> | undefined;
openRoomId: string | undefined;
currentViews: Set<AvailableViews>;
registerView: RegisterView;
unregisterView: UnregisterView;
targetWidgetVisibility: WidgetVisibility;
targetPeer: PeerInfo | undefined;
setOpenRoomId: (openRoomId: string | undefined) => void;
getAutocompleteOptions: (filter: string) => Promise<PeerAutocompleteOptions[]>;
openWidget: (peerInfo?: PeerInfo) => void;
closeWidget: () => void;
setTargetPeer: (peerInfo?: PeerInfo) => void;
};
export const defaultContextValue = {
instance: undefined,
audioElement: undefined,
openRoomId: undefined,
setOpenRoomId: () => undefined,
getAutocompleteOptions: () => Promise.resolve([]),
currentViews: new Set<AvailableViews>(),
registerView: () => undefined,
unregisterView: () => undefined,
openWidget: () => undefined,
closeWidget: () => undefined,
setTargetPeer: () => undefined,
targetWidgetVisibility: 'closed' as const,
targetPeer: undefined,
};
export const MediaCallInstanceContext = createContext<MediaCallInstanceContextValue>(defaultContextValue);
export const useMediaCallInstance = (): MediaCallInstanceContextValue => useContext(MediaCallInstanceContext);

View File

@@ -0,0 +1,73 @@
import { type IMediaStreamWrapper } from '@rocket.chat/media-signaling';
import type { Device } from '@rocket.chat/ui-contexts';
import { createContext, useContext } from 'react';
import type { SessionState, PeerInfo } from './definitions';
import { type LastKnownPosition } from '../providers/useWidgetPositionTracker';
export type MediaCallStreams = {
remoteScreen?: IMediaStreamWrapper;
localScreen?: IMediaStreamWrapper;
};
type MediaCallViewContextValue = {
sessionState: SessionState;
targetPeer?: PeerInfo;
onClickDirectMessage?: () => void;
onMute: () => void;
onHold: () => void;
onDeviceChange: (device: Device) => void;
onForward: () => void;
onTone: (tone: string) => void;
onEndCall: () => void;
onCall: () => Promise<void>;
onAccept: () => Promise<void>;
onSelectPeer: (peerInfo: PeerInfo) => void;
onToggleScreenSharing: () => void;
onOpenPopout: () => void;
onClosePopout: () => void;
streams: MediaCallStreams;
widgetPositionTracker?: {
onChangePosition: (position: LastKnownPosition | null) => void;
lastKnownPosition: LastKnownPosition | null;
};
};
export const defaultSessionState: SessionState = {
state: 'none',
connectionState: 'CONNECTING',
peerInfo: undefined,
transferredBy: undefined,
hidden: false,
muted: false,
held: false,
remoteMuted: false,
remoteHeld: false,
callId: undefined,
startedAt: undefined,
supportedFeatures: ['audio', 'transfer', 'hold'],
};
export const defaultMediaCallContextValue: MediaCallViewContextValue = {
sessionState: defaultSessionState,
targetPeer: undefined,
onMute: () => undefined,
onHold: () => undefined,
onDeviceChange: () => undefined,
onForward: () => undefined,
onTone: () => undefined,
onEndCall: () => undefined,
onCall: () => Promise.resolve(undefined),
onAccept: () => Promise.resolve(undefined),
onSelectPeer: () => undefined,
onToggleScreenSharing: () => undefined,
onOpenPopout: () => undefined,
onClosePopout: () => undefined,
streams: {},
};
const MediaCallViewContext = createContext<MediaCallViewContextValue>(defaultMediaCallContextValue);
export const useMediaCallView = (): MediaCallViewContextValue => useContext(MediaCallViewContext);
export default MediaCallViewContext;

View File

@@ -0,0 +1,50 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import type { CallFeature } from '@rocket.chat/media-signaling';
export type InternalPeerInfo = {
displayName: string;
userId: string;
username?: string;
avatarUrl?: string;
callerId?: string;
status?: UserStatus;
};
export type ExternalPeerInfo = {
number: string;
displayName?: string;
avatarUrl?: string;
};
export type ConnectionState = 'CONNECTED' | 'CONNECTING' | 'RECONNECTING';
export type PeerInfo = InternalPeerInfo | ExternalPeerInfo;
export type State = 'none' | 'calling' | 'ringing' | 'ongoing';
interface IBaseSession {
state: State;
connectionState: ConnectionState;
peerInfo: PeerInfo | undefined;
transferredBy: string | undefined;
muted: boolean;
held: boolean;
remoteMuted: boolean;
remoteHeld: boolean;
startedAt?: Date;
hidden: boolean;
supportedFeatures: readonly CallFeature[];
}
interface IEmptySession extends IBaseSession {
state: Extract<State, 'none'>;
callId: undefined;
}
interface ICallSession extends IBaseSession {
state: Extract<State, 'calling' | 'ringing' | 'ongoing'>;
callId: string;
peerInfo: PeerInfo;
}
export type SessionState = IEmptySession | ICallSession;

View File

@@ -0,0 +1,10 @@
export type * from './definitions';
export { MediaCallInstanceContext, useMediaCallInstance } from './MediaCallInstanceContext';
export { useMediaCallView } from './MediaCallViewContext';
export { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete';
export { useWidgetExternalControls } from './useWidgetExternalControls';
export { usePeekMediaSessionState } from './usePeekMediaSessionState';
export { usePeekMediaSessionCallId } from './usePeekMediaSessionCallId';
export { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo';
export { usePeekMediaSessionFeatures } from './usePeekMediaSessionFeatures';
export type { PeekMediaSessionStateReturn } from './usePeekMediaSessionState';

View File

@@ -0,0 +1,25 @@
import { useCallback, useSyncExternalStore } from 'react';
import { useMediaCallInstance } from './MediaCallInstanceContext';
export type PeekMediaSessionCallIdReturn = string | undefined;
export const usePeekMediaSessionCallId = (): PeekMediaSessionCallIdReturn => {
const { instance } = useMediaCallInstance();
const subscribe = useCallback(
(onStoreChange: () => void): (() => void) => {
if (!instance) {
return () => undefined;
}
return instance?.on('sessionStateChange', onStoreChange);
},
[instance],
);
const getSnapshot = useCallback(() => {
return instance?.getState()?.tempCallId || undefined;
}, [instance]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1,51 @@
import type { CallFeature } from '@rocket.chat/media-signaling';
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { useMediaCallInstance } from './MediaCallInstanceContext';
export type PeekMediaSessionFeaturesReturn = readonly CallFeature[];
const areEqual = (a: PeekMediaSessionFeaturesReturn, b: PeekMediaSessionFeaturesReturn) => {
if (a.length !== b.length) {
return false;
}
return a.every((feature) => b.includes(feature));
};
const emptyFeatures: PeekMediaSessionFeaturesReturn = [];
export const usePeekMediaSessionFeatures = (): PeekMediaSessionFeaturesReturn => {
const { instance } = useMediaCallInstance();
const cache = useRef<PeekMediaSessionFeaturesReturn>([]);
const subscribe = useCallback(
(onStoreChange: () => void): (() => void) => {
if (!instance) {
return () => undefined;
}
return instance?.on('sessionStateChange', onStoreChange);
},
[instance],
);
const getSnapshot = useCallback(() => {
if (!instance) {
return emptyFeatures;
}
const instanceState = instance.getState();
if (!instanceState?.confirmed) {
return emptyFeatures;
}
const { features } = instanceState;
if (!cache.current || !areEqual(features, cache.current)) {
cache.current = features;
return features;
}
return cache.current;
}, [instance]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1,158 @@
import { Emitter } from '@rocket.chat/emitter';
import type { CallContact } from '@rocket.chat/media-signaling';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MediaCallInstanceContext, defaultContextValue } from './MediaCallInstanceContext';
import { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo';
type MockInstance = {
getState: () => { confirmed: true; remoteParticipant: { contact: CallContact } } | null;
on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void;
};
const createWrapper = (instance: MockInstance | undefined) => {
const wrapper = ({ children }: { children?: ReactNode }) => (
<MediaCallInstanceContext.Provider
value={{
...defaultContextValue,
instance: instance as any,
}}
>
{children}
</MediaCallInstanceContext.Provider>
);
return wrapper;
};
describe('usePeekMediaSessionPeerInfo', () => {
it('returns undefined when instance is undefined', () => {
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
wrapper: createWrapper(undefined),
});
expect(result.current).toBeUndefined();
});
it('returns undefined when instance has no main call', () => {
const instance: MockInstance = {
getState: () => null,
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBeUndefined();
});
describe('when main call has a contact', () => {
it('returns external peer info for SIP contact', () => {
const instance: MockInstance = {
getState: () => ({
confirmed: true,
remoteParticipant: {
contact: {
type: 'sip',
id: '+5511999999999',
},
},
}),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
wrapper: createWrapper(instance),
});
expect(result.current).toEqual({ number: '+5511999999999' });
});
it('returns internal peer info for user contact', () => {
const instance: MockInstance = {
getState: () => ({
confirmed: true,
remoteParticipant: {
contact: {
type: 'user',
id: 'userId123',
displayName: 'John Doe',
username: 'johndoe',
sipExtension: '1001',
},
},
}),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
wrapper: createWrapper(instance),
});
expect(result.current).toEqual({
displayName: 'John Doe',
userId: 'userId123',
username: 'johndoe',
callerId: '1001',
});
});
});
describe('sessionStateChange subscription', () => {
it('updates peer info when sessionStateChange is emitted', () => {
const emitter = new Emitter<{ sessionStateChange: void }>();
const defaultInstanceState = {
confirmed: true as const,
remoteParticipant: {
contact: {
type: 'sip' as const,
id: '+5511999999999',
} as CallContact,
},
};
let instanceState: typeof defaultInstanceState | null = defaultInstanceState;
const instance: MockInstance = {
getState: () => instanceState,
on: (event, onStoreChange) => emitter.on(event, onStoreChange),
};
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
wrapper: createWrapper(instance),
});
expect(result.current).toEqual({ number: '+5511999999999' });
act(() => {
instanceState = null;
emitter.emit('sessionStateChange');
});
expect(result.current).toBeUndefined();
act(() => {
instanceState = {
confirmed: true,
remoteParticipant: {
contact: {
type: 'user',
id: 'userId456',
displayName: 'Jane Smith',
},
},
};
emitter.emit('sessionStateChange');
});
expect(result.current).toEqual({
displayName: 'Jane Smith',
userId: 'userId456',
username: undefined,
callerId: undefined,
});
});
});
});

View File

@@ -0,0 +1,46 @@
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { useMediaCallInstance } from './MediaCallInstanceContext';
import type { PeerInfo } from './definitions';
import { derivePeerInfoFromInstanceState } from '../utils/derivePeerInfoFromInstanceState';
const areEqual = (a: PeerInfo, b: PeerInfo) => {
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
}
return Object.keys(a).every((key) => a[key as keyof PeerInfo] === b[key as keyof PeerInfo]);
};
export const usePeekMediaSessionPeerInfo = (): PeerInfo | undefined => {
const { instance } = useMediaCallInstance();
const cache = useRef<PeerInfo | undefined>(undefined);
const subscribe = useCallback(
(onStoreChange: () => void): (() => void) => {
if (!instance) {
return () => undefined;
}
return instance?.on('sessionStateChange', onStoreChange);
},
[instance],
);
const getSnapshot = useCallback(() => {
if (!instance) {
return undefined;
}
const instanceState = instance.getState();
if (!instanceState) {
return undefined;
}
const peerInfo = derivePeerInfoFromInstanceState(instanceState);
if (!cache.current || !areEqual(peerInfo, cache.current)) {
cache.current = peerInfo;
return peerInfo;
}
return cache.current;
}, [instance]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1,138 @@
import { Emitter } from '@rocket.chat/emitter';
import type { CallRole, CallState } from '@rocket.chat/media-signaling';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { usePeekMediaSessionState } from './usePeekMediaSessionState';
import MockedInstanceProvider from '../providers/MockedInstanceProvider';
type MockInstance = {
getState: () => { state: CallState; localParticipant: { role: CallRole } } | null;
on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void;
};
const createWrapper = (instance: MockInstance | undefined) => {
const wrapper = ({ children }: { children?: ReactNode }) => (
<MockedInstanceProvider instance={instance as any}>{children}</MockedInstanceProvider>
);
return wrapper;
};
describe('usePeekMediaSessionState', () => {
it('returns "unavailable" when instance is undefined', () => {
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(undefined),
});
expect(result.current).toBe('unavailable');
});
it('returns "available" when instance has no main call', () => {
const instance: MockInstance = {
getState: () => null,
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe('available');
});
it('returns "available" when main call state does not map to a widget state (e.g. hangup)', () => {
const instance: MockInstance = {
getState: () => ({ state: 'hangup', localParticipant: { role: 'caller' } }),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe('available');
});
describe('when main call maps to a widget state', () => {
it.each([
['active', 'caller', 'ongoing'] as const,
['active', 'callee', 'ongoing'] as const,
['renegotiating', 'callee', 'ongoing'] as const,
['renegotiating', 'caller', 'ongoing'] as const,
])('returns "ongoing" for state "%s" and role "%s"', (callState, role, expected) => {
const instance: MockInstance = {
getState: () => ({ state: callState, localParticipant: { role } }),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe(expected);
});
it.each(['ringing', 'none', 'accepted'] as const)('returns "ringing" for callee when state is "%s"', (state) => {
const instance: MockInstance = {
getState: () => ({ state, localParticipant: { role: 'callee' } }),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe('ringing');
});
it.each(['ringing', 'none', 'accepted'] as const)('returns "calling" for caller when state is "%s"', (state) => {
const instance: MockInstance = {
getState: () => ({ state, localParticipant: { role: 'caller' } }),
on: () => () => undefined,
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe('calling');
});
});
describe('sessionStateChange subscription', () => {
it('updates state when sessionStateChange is emitted', () => {
const emitter = new Emitter<{ sessionStateChange: void }>();
let instanceState: { state: CallState; localParticipant: { role: CallRole } } | null = {
state: 'active',
localParticipant: {
role: 'caller',
},
};
const instance: MockInstance = {
getState: () => instanceState,
on: (event, onStoreChange) => emitter.on(event, onStoreChange),
};
const { result } = renderHook(() => usePeekMediaSessionState(), {
wrapper: createWrapper(instance),
});
expect(result.current).toBe('ongoing');
act(() => {
instanceState = null;
emitter.emit('sessionStateChange');
});
expect(result.current).toBe('available');
act(() => {
instanceState = { state: 'ringing', localParticipant: { role: 'callee' } };
emitter.emit('sessionStateChange');
});
expect(result.current).toBe('ringing');
});
});
});

View File

@@ -0,0 +1,44 @@
import { useCallback, useSyncExternalStore } from 'react';
import { useMediaCallInstance } from './MediaCallInstanceContext';
import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState';
export type PeekMediaSessionStateReturn = 'unavailable' | 'available' | 'ongoing' | 'ringing' | 'calling';
export const usePeekMediaSessionState = (): PeekMediaSessionStateReturn => {
const { instance } = useMediaCallInstance();
const subscribe = useCallback(
(onStoreChange: () => void): (() => void) => {
if (!instance) {
return () => undefined;
}
return instance?.on('sessionStateChange', onStoreChange);
},
[instance],
);
const getSnapshot = useCallback(() => {
if (!instance) {
return 'unavailable';
}
const instanceState = instance.getState();
if (!instanceState) {
return 'available';
}
const {
state: callState,
localParticipant: { role },
} = instanceState;
const state = deriveWidgetStateFromCallState(callState, role);
if (!state) {
return 'available';
}
return state;
}, [instance]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1,319 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { PeerInfo } from './definitions';
import type { PeerAutocompleteOptions } from '../components';
import { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete';
import MockedInstanceProvider from '../providers/MockedInstanceProvider';
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useUserPresence: jest.fn(() => ({ _id: 'user1', status: 'online' as const })),
}));
jest.useFakeTimers();
const mockGetAutocompleteOptions = jest.fn();
const mockOnSelectPeer = jest.fn();
const appRoot = () =>
mockAppRoot()
.wrap((children) => <MockedInstanceProvider getAutocompleteOptions={mockGetAutocompleteOptions}>{children}</MockedInstanceProvider>)
.build();
beforeEach(() => {
jest.clearAllMocks();
});
describe('isFirstPeerAutocompleteOption', () => {
it('should return true for values starting with prefix', () => {
expect(isFirstPeerAutocompleteOption('rcx-first-option-test')).toBe(true);
});
it('should return false for values not starting with prefix', () => {
expect(isFirstPeerAutocompleteOption('test')).toBe(false);
});
});
describe('hook', () => {
it('should initialize with empty filter and no value', () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
expect(result.current.filter).toBe('');
expect(result.current.value).toBeUndefined();
});
it('should update filter when onChangeFilter is called', async () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
act(() => {
result.current.onChangeFilter('test');
});
expect(result.current.filter).toBe('test');
});
it('should fetch autocomplete options', async () => {
const mockOptions: PeerAutocompleteOptions[] = [
{ value: 'user1', label: 'User 1', avatarUrl: '' },
{ value: 'user2', label: 'User 2', avatarUrl: '' },
];
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(result.current.options).toEqual(mockOptions);
});
});
it('should add first option when filter is not empty', async () => {
const mockOptions: PeerAutocompleteOptions[] = [{ value: 'user1', label: 'User 1', avatarUrl: '' }];
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
act(() => {
result.current.onChangeFilter('123');
});
await waitFor(() => {
expect(result.current.options).toHaveLength(2);
expect(result.current.options[0]).toEqual({
value: 'rcx-first-option-123',
label: '123',
avatarUrl: '',
});
});
});
it('should return value when peerInfo has userId', () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' };
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
expect(result.current.value).toBe('user1');
});
it('should return undefined value when peerInfo has no userId', () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { number: '123456' };
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
expect(result.current.value).toBeUndefined();
});
describe('onChangeValue', () => {
it('should do nothing if value is an array', async () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
act(() => {
result.current.onChangeValue(['value1', 'value2']);
});
expect(mockOnSelectPeer).not.toHaveBeenCalled();
});
it('should call onSelectPeer with number when value is first option', async () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
act(() => {
result.current.onChangeValue('rcx-first-option-123456');
});
expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '123456' });
});
it('should call onSelectPeer with full peer info when value matches option', async () => {
const mockOptions: PeerAutocompleteOptions[] = [
{ value: 'user1', label: 'User 1', avatarUrl: 'avatar.jpg', status: UserStatus.ONLINE },
];
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(result.current.options).toHaveLength(1);
});
act(() => {
result.current.onChangeValue('user1');
});
expect(mockOnSelectPeer).toHaveBeenCalledWith({
userId: 'user1',
displayName: 'User 1',
avatarUrl: 'avatar.jpg',
status: UserStatus.ONLINE,
});
});
it('should throw error when value does not match any option', async () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(result.current.options).toEqual([]);
});
expect(() => {
act(() => {
result.current.onChangeValue('unknown-user');
});
}).toThrow('Peer info not found for value: unknown-user');
});
});
describe('onKeypadPress', () => {
it('should append key to filter', async () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
act(() => {
result.current.onKeypadPress('1');
});
expect(result.current.filter).toBe('1');
act(() => {
result.current.onKeypadPress('2');
});
expect(result.current.filter).toBe('12');
act(() => {
result.current.onKeypadPress('3');
});
expect(result.current.filter).toBe('123');
});
});
describe('user presence updates', () => {
it('should update peer status when user presence changes', async () => {
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.AWAY, statusText: '' });
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(mockOnSelectPeer).toHaveBeenCalledWith({
...peerInfo,
status: 'away',
});
});
});
it('should not update peer status when status has not changed', async () => {
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(mockOnSelectPeer).not.toHaveBeenCalled();
});
});
it('should not update when peerInfo is undefined', async () => {
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
mockGetAutocompleteOptions.mockResolvedValue([]);
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(mockOnSelectPeer).not.toHaveBeenCalled();
});
});
it('should not update when peerInfo has no status property', async () => {
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { number: '123456' };
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(mockOnSelectPeer).not.toHaveBeenCalled();
});
});
it('should not update when useUserPresence returns no status', async () => {
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
mockUseUserPresence.mockReturnValue(undefined);
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
});
await waitFor(() => {
expect(mockOnSelectPeer).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,89 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useUserPresence } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import type { PeerInfo } from './definitions';
import type { PeerAutocompleteOptions } from '../components';
import { useMediaCallInstance } from './MediaCallInstanceContext';
import { mediaCallQueryKeys } from '../utils/queryKeys';
const PREFIX_FIRST_OPTION = 'rcx-first-option-';
export const isFirstPeerAutocompleteOption = (value: string) => {
return value.startsWith(PREFIX_FIRST_OPTION);
};
const getFirstOption = (filter: string): PeerAutocompleteOptions => {
return { value: `${PREFIX_FIRST_OPTION}${filter}`, label: filter, avatarUrl: '' };
};
export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, peerInfo: PeerInfo | undefined) => {
const { getAutocompleteOptions } = useMediaCallInstance();
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 400);
const { data: options } = useQuery({
queryKey: mediaCallQueryKeys.peerAutocomplete(debouncedFilter),
queryFn: async () => {
const options = await getAutocompleteOptions(debouncedFilter);
if (debouncedFilter.length > 0) {
return [getFirstOption(debouncedFilter), ...options];
}
return options;
},
placeholderData: keepPreviousData,
initialData: [],
});
const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined);
useEffect(() => {
if (!peerInfo || !('status' in peerInfo) || !status?.status) {
return;
}
if (status.status === peerInfo?.status) {
return;
}
onSelectPeer({
...peerInfo,
status: status.status,
});
}, [status, peerInfo, onSelectPeer]);
return {
options,
onChangeFilter: setFilter,
onChangeValue: (value: string | string[]) => {
if (Array.isArray(value)) {
return;
}
if (isFirstPeerAutocompleteOption(value)) {
onSelectPeer({ number: value.replace(PREFIX_FIRST_OPTION, '') });
return;
}
const localInfo = options.find((option) => option.value === value);
if (!localInfo) {
throw new Error(`Peer info not found for value: ${value}`);
}
onSelectPeer({
userId: localInfo.value,
displayName: localInfo.label,
avatarUrl: localInfo.avatarUrl,
status: localInfo.status,
});
},
value: peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined,
filter,
onKeypadPress: (key: string) => setFilter((filter) => filter + key),
};
};

View File

@@ -0,0 +1,18 @@
import { useLayoutEffect } from 'react';
import { useMediaCallInstance } from '.';
import type { AvailableViews } from './MediaCallInstanceContext';
const useRegisterView = (view: AvailableViews) => {
const { currentViews, registerView, unregisterView } = useMediaCallInstance();
useLayoutEffect(() => {
registerView(view);
return () => unregisterView(view);
}, [view, registerView, unregisterView]);
return currentViews;
};
export default useRegisterView;

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import { useMediaCallInstance } from './MediaCallInstanceContext';
import type { PeerInfo } from './definitions';
import { getEndCall } from '../utils/instanceControlsGetters';
export const useWidgetExternalControls = () => {
const { instance, openWidget, closeWidget, targetWidgetVisibility } = useMediaCallInstance();
const toggleWidget = useCallback(
(peerInfo?: PeerInfo) => {
if (targetWidgetVisibility === 'closed') {
openWidget(peerInfo);
return;
}
closeWidget();
},
[closeWidget, openWidget, targetWidgetVisibility],
);
const endCall = useCallback(() => getEndCall(instance)(), [instance]);
return { toggleWidget, endCall };
};