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
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:
52
packages/ui-voip/src/context/MediaCallInstanceContext.ts
Normal file
52
packages/ui-voip/src/context/MediaCallInstanceContext.ts
Normal 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);
|
||||
73
packages/ui-voip/src/context/MediaCallViewContext.ts
Normal file
73
packages/ui-voip/src/context/MediaCallViewContext.ts
Normal 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;
|
||||
50
packages/ui-voip/src/context/definitions.d.ts
vendored
Normal file
50
packages/ui-voip/src/context/definitions.d.ts
vendored
Normal 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;
|
||||
10
packages/ui-voip/src/context/index.ts
Normal file
10
packages/ui-voip/src/context/index.ts
Normal 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';
|
||||
25
packages/ui-voip/src/context/usePeekMediaSessionCallId.ts
Normal file
25
packages/ui-voip/src/context/usePeekMediaSessionCallId.ts
Normal 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);
|
||||
};
|
||||
51
packages/ui-voip/src/context/usePeekMediaSessionFeatures.ts
Normal file
51
packages/ui-voip/src/context/usePeekMediaSessionFeatures.ts
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts
Normal file
46
packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts
Normal 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);
|
||||
};
|
||||
138
packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx
Normal file
138
packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
packages/ui-voip/src/context/usePeekMediaSessionState.ts
Normal file
44
packages/ui-voip/src/context/usePeekMediaSessionState.ts
Normal 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);
|
||||
};
|
||||
319
packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Normal file
319
packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/ui-voip/src/context/usePeerAutocomplete.ts
Normal file
89
packages/ui-voip/src/context/usePeerAutocomplete.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
18
packages/ui-voip/src/context/useRegisterView.ts
Normal file
18
packages/ui-voip/src/context/useRegisterView.ts
Normal 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;
|
||||
24
packages/ui-voip/src/context/useWidgetExternalControls.ts
Normal file
24
packages/ui-voip/src/context/useWidgetExternalControls.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user