Skip to content

Commit 04f2685

Browse files
gabriellshjeanfbritotassoevan
authored
feat: Media call notifications on desktop app (#37492)
Co-authored-by: Jean Brito <[email protected]> Co-authored-by: Tasso Evangelista <[email protected]>
1 parent 4ecebe8 commit 04f2685

20 files changed

+207
-53
lines changed

.changeset/sixty-numbers-tan.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@rocket.chat/i18n": minor
3+
"@rocket.chat/ui-voip": minor
4+
---
5+
6+
Introduces native desktop notifications for voice calls when using the desktop app.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2560,6 +2560,7 @@
25602560
"Incoming_Livechats": "Queued chats",
25612561
"Incoming_WebHook": "Incoming WebHook",
25622562
"Incoming_call": "Incoming call",
2563+
"Incoming_call_ellipsis": "Incoming call...",
25632564
"Incoming_call_from": "Incoming call from",
25642565
"Incoming_call_from__roomName__": "Incoming call from {{roomName}}",
25652566
"Incoming_call_transfer": "Incoming call transfer",

packages/ui-voip/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
2020
},
2121
"dependencies": {
22+
"@rocket.chat/desktop-api": "workspace:^",
2223
"@rocket.chat/emitter": "~0.31.25",
2324
"@rocket.chat/media-signaling": "workspace:~",
2425
"@tanstack/react-query": "~5.65.1",

packages/ui-voip/src/global.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';
2+
3+
declare global {
4+
interface Window {
5+
RocketChatDesktop?: IRocketChatDesktop;
6+
}
7+
}

packages/ui-voip/src/v2/MediaCallProvider.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,26 @@ import {
1111
useToastMessageDispatch,
1212
useSetting,
1313
} from '@rocket.chat/ui-contexts';
14-
import { useCallback, useEffect } from 'react';
14+
import { ReactNode, useCallback, useEffect } from 'react';
1515
import { createPortal } from 'react-dom';
1616
import { useTranslation } from 'react-i18next';
1717

1818
import MediaCallContext, { PeerInfo } from './MediaCallContext';
1919
import MediaCallWidget from './MediaCallWidget';
2020
import TransferModal from './TransferModal';
2121
import { useCallSounds } from './useCallSounds';
22+
import { useDesktopNotifications } from './useDesktopNotifications';
2223
import { getExtensionFromPeerInfo, useMediaSession } from './useMediaSession';
2324
import { useMediaSessionInstance } from './useMediaSessionInstance';
2425
import useMediaStream from './useMediaStream';
2526
import { isValidTone, useTonePlayer } from './useTonePlayer';
2627
import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt';
2728

28-
const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
29+
type MediaCallProviderProps = {
30+
children: ReactNode;
31+
};
32+
33+
const MediaCallProvider = ({ children }: MediaCallProviderProps) => {
2934
const user = useUser();
3035
const { t } = useTranslation();
3136
const dispatchToastMessage = useToastMessageDispatch();
@@ -37,6 +42,8 @@ const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
3742
const instance = useMediaSessionInstance(userId ?? undefined);
3843
const session = useMediaSession(instance);
3944

45+
useDesktopNotifications(session);
46+
4047
const [remoteStreamRefCallback, audioElement] = useMediaStream(instance);
4148

4249
const setOutputMediaDevice = useSetOutputMediaDevice();

packages/ui-voip/src/v2/MediaCallWidget.stories.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Meta, StoryFn, StoryObj } from '@storybook/react';
44

55
import { useMediaCallContext } from './MediaCallContext';
66
import MediaCallWidget from './MediaCallWidget';
7-
import MediaCallProviderMock from './MockedMediaCallProvider';
7+
import MockedMediaCallProvider from './MockedMediaCallProvider';
88

99
const mockedContexts = mockAppRoot()
1010
.withTranslations('en', 'core', {
@@ -26,9 +26,9 @@ const meta = {
2626
decorators: [
2727
mockedContexts,
2828
(Story, options) => (
29-
<MediaCallProviderMock {...options.args}>
29+
<MockedMediaCallProvider {...options.args}>
3030
<Story />
31-
</MediaCallProviderMock>
31+
</MockedMediaCallProvider>
3232
),
3333
],
3434
} satisfies Meta<typeof MediaCallWidget>;

packages/ui-voip/src/v2/MockedMediaCallProvider.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
import { UserStatus } from '@rocket.chat/core-typings';
2-
import { useState } from 'react';
2+
import { ReactNode, useState } from 'react';
33

44
import MediaCallContext from './MediaCallContext';
55
import type { State, PeerInfo } from './MediaCallContext';
66

77
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`;
88
const myData: any[] = Array.from({ length: 100 }, (_, i) => ({ value: `user-${i}`, label: `User ${i}`, identifier: `000${i}`, avatarUrl }));
99

10-
const MediaCallProviderMock = ({
10+
type MockedMediaCallProviderProps = {
11+
children: ReactNode;
12+
state?: State;
13+
transferredBy?: string;
14+
remoteMuted?: boolean;
15+
remoteHeld?: boolean;
16+
muted?: boolean;
17+
held?: boolean;
18+
};
19+
20+
const MockedMediaCallProvider = ({
1121
children,
1222
state = 'closed',
1323
transferredBy = undefined,
1424
remoteMuted = false,
1525
remoteHeld = false,
1626
muted = false,
1727
held = false,
18-
}: {
19-
children: React.ReactNode;
20-
state?: State;
21-
transferredBy?: string;
22-
remoteMuted?: boolean;
23-
remoteHeld?: boolean;
24-
muted?: boolean;
25-
held?: boolean;
26-
}) => {
28+
}: MockedMediaCallProviderProps) => {
2729
const [peerInfo, setPeerInfo] = useState<PeerInfo | undefined>({
2830
displayName: 'John Doe',
2931
userId: '1234567890',
@@ -147,4 +149,4 @@ const MediaCallProviderMock = ({
147149
return <MediaCallContext.Provider value={contextValue}>{children}</MediaCallContext.Provider>;
148150
};
149151

150-
export default MediaCallProviderMock;
152+
export default MockedMediaCallProvider;

packages/ui-voip/src/v2/components/Widget/Widget.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Palette } from '@rocket.chat/fuselage';
22
import styled from '@rocket.chat/styled';
3-
import { ComponentProps, useLayoutEffect } from 'react';
3+
import { ComponentProps, ReactNode, useLayoutEffect } from 'react';
44
import { FocusScope } from 'react-aria';
55

66
import { DragContext } from './WidgetDraggableContext';
@@ -28,7 +28,7 @@ const WidgetBase = styled('article')`
2828
`;
2929

3030
type WidgetProps = {
31-
children: React.ReactNode;
31+
children: ReactNode;
3232
} & ComponentProps<typeof WidgetBase>;
3333

3434
const Widget = ({ children, ...props }: WidgetProps) => {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useEffect, useRef } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import { PeerInfo } from './MediaCallContext';
5+
import { SessionInfo } from './useMediaSessionInstance';
6+
import { convertAvatarUrlToPng } from './utils/convertAvatarUrlToPng';
7+
8+
const getDisplayInfo = (peerInfo?: PeerInfo) => {
9+
if (!peerInfo) {
10+
return undefined;
11+
}
12+
13+
if ('number' in peerInfo) {
14+
return { title: peerInfo.number };
15+
}
16+
if ('displayName' in peerInfo) {
17+
return { title: peerInfo.displayName, avatar: peerInfo.avatarUrl };
18+
}
19+
return undefined;
20+
};
21+
22+
export const useDesktopNotifications = (sessionInfo: SessionInfo) => {
23+
const previousCallId = useRef<string | undefined>(undefined);
24+
const { t } = useTranslation();
25+
26+
const displayInfo = getDisplayInfo(sessionInfo.peerInfo);
27+
useEffect(() => {
28+
if (
29+
typeof window.RocketChatDesktop?.dispatchCustomNotification !== 'function' ||
30+
typeof window.RocketChatDesktop?.closeCustomNotification !== 'function'
31+
) {
32+
return;
33+
}
34+
35+
let isMounted = true;
36+
37+
if (sessionInfo.state !== 'ringing') {
38+
if (previousCallId.current) {
39+
window.RocketChatDesktop.closeCustomNotification(previousCallId.current);
40+
previousCallId.current = undefined;
41+
}
42+
return;
43+
}
44+
45+
if (!displayInfo?.title) {
46+
return;
47+
}
48+
49+
const notifyDesktop = async () => {
50+
const avatarAsPng = await convertAvatarUrlToPng(displayInfo.avatar);
51+
52+
if (!isMounted) {
53+
return;
54+
}
55+
56+
window.RocketChatDesktop?.dispatchCustomNotification({
57+
type: 'voice',
58+
id: sessionInfo.callId,
59+
payload: {
60+
title: displayInfo.title,
61+
body: t('Incoming_call_ellipsis'),
62+
avatar: avatarAsPng || undefined,
63+
requireInteraction: true,
64+
},
65+
});
66+
};
67+
68+
notifyDesktop();
69+
previousCallId.current = sessionInfo.callId;
70+
71+
return () => {
72+
isMounted = false;
73+
};
74+
}, [displayInfo?.avatar, displayInfo?.title, sessionInfo.callId, sessionInfo.state, t]);
75+
};

packages/ui-voip/src/v2/useKeypad.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Divider, Box, TextInput, Field, FieldRow } from '@rocket.chat/fuselage';
2-
import { useState } from 'react';
2+
import { ReactNode, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44

55
import { Keypad } from './components';
66

77
type UseKeypad = {
8-
element: React.ReactNode;
8+
element: ReactNode;
99
buttonProps: {
1010
title: string;
1111
onClick: () => void;

0 commit comments

Comments
 (0)