Skip to content

Commit aa05adc

Browse files
authored
feat: Media Call UI (RocketChat#36594)
1 parent 903e0db commit aa05adc

42 files changed

Lines changed: 1966 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/i18n/src/locales/en.i18n.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,6 +1953,7 @@
19531953
"Enter_the_code_provided_by_your_authentication_app_to_continue": "Enter the code provided by your authentication app to continue. You can also use one of your backup codes.",
19541954
"Enter_the_code_we_just_emailed_you": "Enter the code we just emailed you.",
19551955
"Enter_to": "Enter to",
1956+
"Enter_username_or_number": "Enter username or number",
19561957
"Enter_your_E2E_password": "Enter your E2E password",
19571958
"Enter_your_E2E_password_to_access": "Enter your end-to-end encryption password to access",
19581959
"Enter_your_password_to_delete_your_account": "Enter your password to delete your account. This cannot be undone.",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
2+
import { keepPreviousData, useQuery } from '@tanstack/react-query';
3+
import { createContext, useContext, useState } from 'react';
4+
5+
import type { PeerAutocompleteOptions } from './components';
6+
7+
type InternalPeerInfo = {
8+
name: string;
9+
avatarUrl: string;
10+
identifier: string;
11+
};
12+
13+
type ExternalPeerInfo = {
14+
number: string;
15+
};
16+
17+
export type PeerInfo = InternalPeerInfo | ExternalPeerInfo;
18+
19+
export type State = 'closed' | 'new' | 'calling' | 'ringing' | 'ongoing';
20+
21+
type MediaCallContextType = {
22+
state: State;
23+
24+
peerInfo: PeerInfo | undefined;
25+
26+
muted: boolean;
27+
held: boolean;
28+
onMute: () => void;
29+
onHold: () => void;
30+
31+
onDeviceChange: (device: string) => void;
32+
onForward: () => void;
33+
onTone: (tone: string) => void;
34+
35+
// onCall and onEndCall are used to start/accept and reject/end a call
36+
onEndCall: () => void;
37+
// TODO: Not sure if we need to pass the peerId to the callback, or if it should be a state stored somewhere else in the context.
38+
onCall: (peerId?: string) => void;
39+
40+
onToggleWidget: () => void;
41+
42+
getAutocompleteOptions: (filter: string) => Promise<PeerAutocompleteOptions[]>;
43+
// This is used to get the peer info from the server in case it's not available in the autocomplete options.
44+
getPeerInfo: (id: string) => Promise<PeerInfo | undefined>;
45+
};
46+
47+
const MediaCallContext = createContext<MediaCallContextType>({
48+
state: 'closed',
49+
50+
peerInfo: undefined,
51+
52+
muted: false,
53+
held: false,
54+
onMute: () => undefined,
55+
onHold: () => undefined,
56+
57+
onDeviceChange: () => undefined,
58+
onForward: () => undefined,
59+
onTone: () => undefined,
60+
61+
onEndCall: () => undefined,
62+
onCall: () => undefined,
63+
64+
onToggleWidget: () => undefined,
65+
66+
getAutocompleteOptions: () => Promise.resolve([]),
67+
getPeerInfo: () => Promise.resolve(undefined),
68+
});
69+
70+
export const useMediaCallContext = (): MediaCallContextType => {
71+
return useContext(MediaCallContext);
72+
};
73+
74+
const PREFIX_FIRST_OPTION = 'rcx-first-option-';
75+
76+
export const isFirstPeerAutocompleteOption = (value: string) => {
77+
return value.startsWith(PREFIX_FIRST_OPTION);
78+
};
79+
80+
const getFirstOption = (filter: string): PeerAutocompleteOptions => {
81+
return { value: `${PREFIX_FIRST_OPTION}${filter}`, label: filter, avatarUrl: '' };
82+
};
83+
84+
export const usePeerAutocomplete = () => {
85+
const { getAutocompleteOptions, getPeerInfo } = useMediaCallContext();
86+
const [selected, setSelected] = useState<string | undefined>();
87+
const [filter, setFilter] = useState('');
88+
89+
const debouncedFilter = useDebouncedValue(filter, 400);
90+
91+
const { data: options } = useQuery({
92+
queryKey: ['mediaCall/peerAutocomplete', debouncedFilter],
93+
queryFn: async () => {
94+
const options = await getAutocompleteOptions(debouncedFilter);
95+
96+
if (debouncedFilter.length > 0) {
97+
return [getFirstOption(debouncedFilter), ...options];
98+
}
99+
100+
return options;
101+
},
102+
placeholderData: keepPreviousData,
103+
initialData: [],
104+
});
105+
106+
const { data: peerInfo } = useQuery({
107+
queryKey: ['mediaCall/peerInfo', selected],
108+
queryFn: async () => {
109+
if (!selected) {
110+
return undefined;
111+
}
112+
113+
const localInfo = options.find((option) => option.value === selected);
114+
115+
if (localInfo) {
116+
return {
117+
name: localInfo.label,
118+
avatarUrl: localInfo.avatarUrl || '',
119+
identifier: localInfo.value,
120+
};
121+
}
122+
123+
const peerInfo = await getPeerInfo(selected);
124+
125+
return peerInfo;
126+
},
127+
enabled: !!selected,
128+
});
129+
130+
return {
131+
options,
132+
peerInfo,
133+
onChangeFilter: setFilter,
134+
onChangeValue: (value: string | string[]) => {
135+
if (Array.isArray(value)) {
136+
return;
137+
}
138+
139+
setSelected(value);
140+
},
141+
value: selected,
142+
filter,
143+
onKeypadPress: (key: string) => setFilter((filter) => filter + key),
144+
};
145+
};
146+
147+
export default MediaCallContext;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { mockAppRoot } from '@rocket.chat/mock-providers';
2+
import { composeStories } from '@storybook/react';
3+
import { render } from '@testing-library/react';
4+
import { axe } from 'jest-axe';
5+
6+
import * as stories from './MediaCallWidget.stories';
7+
8+
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
9+
10+
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
11+
const view = render(<Story />, { wrapper: mockAppRoot().build() });
12+
expect(view.baseElement).toMatchSnapshot();
13+
});
14+
15+
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
16+
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
17+
18+
const results = await axe(container);
19+
expect(results).toHaveNoViolations();
20+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Button } from '@rocket.chat/fuselage';
2+
import { mockAppRoot } from '@rocket.chat/mock-providers';
3+
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
4+
5+
import { useMediaCallContext } from './MediaCallContext';
6+
import MediaCallWidget from './MediaCallWidget';
7+
import MediaCallProviderMock from './MockedMediaCallProvider';
8+
9+
const mockedContexts = mockAppRoot()
10+
.withTranslations('en', 'core', {
11+
New_Call: 'New Call',
12+
Incoming_call: 'Incoming Call',
13+
Enter_username_or_number: 'Enter username or number',
14+
Call: 'Call',
15+
Calling: 'Calling',
16+
Cancel: 'Cancel',
17+
})
18+
.buildStoryDecorator();
19+
20+
const meta = {
21+
title: 'V2/MediaCallWidget',
22+
component: MediaCallWidget,
23+
args: {
24+
state: 'closed',
25+
},
26+
decorators: [
27+
mockedContexts,
28+
(Story, options) => (
29+
<MediaCallProviderMock {...options.args}>
30+
<Story />
31+
</MediaCallProviderMock>
32+
),
33+
],
34+
} satisfies Meta<typeof MediaCallWidget>;
35+
export default meta;
36+
37+
type Story = StoryObj<typeof meta>;
38+
39+
export const MediaCallWidgetManualTesting: StoryFn<typeof MediaCallWidget> = () => {
40+
const { onToggleWidget, onCall, state } = useMediaCallContext();
41+
return (
42+
<>
43+
<Button onClick={onToggleWidget} disabled={state !== 'new' && state !== 'closed'} mie={8}>
44+
Toggle widget
45+
</Button>
46+
<Button onClick={() => onCall()} disabled={state !== 'closed'}>
47+
Receive call
48+
</Button>
49+
<MediaCallWidget />
50+
</>
51+
);
52+
};
53+
54+
export const NewCall: Story = {
55+
args: {
56+
state: 'new',
57+
},
58+
};
59+
60+
export const IncomingCall: Story = {
61+
args: {
62+
state: 'ringing',
63+
},
64+
};
65+
66+
export const Calling: Story = {
67+
args: {
68+
state: 'calling',
69+
},
70+
};
71+
72+
export const OngoingCall: Story = {
73+
args: {
74+
state: 'ongoing',
75+
},
76+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useMediaCallContext } from './MediaCallContext';
2+
import { OngoingCall, NewCall, IncomingCall, OutgoingCall } from './views';
3+
4+
const MediaCallWidget = () => {
5+
const { state } = useMediaCallContext();
6+
7+
switch (state) {
8+
case 'ongoing':
9+
return <OngoingCall />;
10+
case 'new':
11+
return <NewCall />;
12+
case 'ringing':
13+
return <IncomingCall />;
14+
case 'calling':
15+
return <OutgoingCall />;
16+
case 'closed':
17+
default:
18+
return null;
19+
}
20+
};
21+
22+
export default MediaCallWidget;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { useState } from 'react';
2+
3+
import MediaCallContext from './MediaCallContext';
4+
import type { State, PeerInfo } from './MediaCallContext';
5+
6+
const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`;
7+
const myData: any[] = Array.from({ length: 100 }, (_, i) => ({ value: `user-${i}`, label: `User ${i}`, identifier: `000${i}`, avatarUrl }));
8+
9+
const MediaCallProviderMock = ({ children, state = 'closed' }: { children: React.ReactNode; state?: State }) => {
10+
const [peerInfo, setPeerInfo] = useState<PeerInfo | undefined>({
11+
name: 'John Doe',
12+
avatarUrl,
13+
identifier: '1234567890',
14+
});
15+
const [widgetState, setWidgetState] = useState<State>(state);
16+
const [muted, setMuted] = useState(false);
17+
const [held, setHeld] = useState(false);
18+
19+
const onMute = () => setMuted((prev) => !prev);
20+
const onHold = () => setHeld((prev) => !prev);
21+
22+
const clearState = () => {
23+
setMuted(false);
24+
setHeld(false);
25+
};
26+
27+
const onDeviceChange = (device: string) => {
28+
console.log('device', device);
29+
};
30+
31+
const onForward = () => {
32+
console.log('forward');
33+
clearState();
34+
setWidgetState('closed');
35+
};
36+
37+
const onTone = (tone: string) => {
38+
console.log('tone', tone);
39+
};
40+
41+
const onEndCall = () => {
42+
clearState();
43+
setWidgetState('closed');
44+
};
45+
46+
const getAutocompleteOptions = (filter: string) =>
47+
Promise.resolve(myData.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase())));
48+
49+
const getPeerInfo = (id: string) => {
50+
const peerInfo = myData.find((item) => item.value === id);
51+
if (!peerInfo) {
52+
return Promise.resolve(undefined);
53+
}
54+
55+
return Promise.resolve({
56+
name: peerInfo.label,
57+
avatarUrl: peerInfo.avatarUrl,
58+
identifier: peerInfo.value,
59+
});
60+
};
61+
62+
const onCall = async (id?: string) => {
63+
if (id) {
64+
setPeerInfo(await getPeerInfo(id));
65+
}
66+
67+
switch (widgetState) {
68+
case 'closed':
69+
setWidgetState('ringing');
70+
break;
71+
case 'ringing':
72+
setWidgetState('ongoing');
73+
break;
74+
case 'new':
75+
setWidgetState('calling');
76+
setTimeout(() => {
77+
setWidgetState('ongoing');
78+
}, 1000);
79+
break;
80+
case 'calling':
81+
setWidgetState('closed');
82+
break;
83+
}
84+
};
85+
86+
const onToggleWidget = () => {
87+
switch (widgetState) {
88+
case 'closed':
89+
setWidgetState('new');
90+
break;
91+
case 'new':
92+
setWidgetState('closed');
93+
break;
94+
}
95+
};
96+
97+
const contextValue = {
98+
state: widgetState,
99+
peerInfo,
100+
muted,
101+
held,
102+
onMute,
103+
onHold,
104+
onDeviceChange,
105+
onForward,
106+
onTone,
107+
onEndCall,
108+
onCall,
109+
onToggleWidget,
110+
getAutocompleteOptions,
111+
getPeerInfo,
112+
};
113+
114+
return <MediaCallContext.Provider value={contextValue}>{children}</MediaCallContext.Provider>;
115+
};
116+
117+
export default MediaCallProviderMock;

0 commit comments

Comments
 (0)