Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sixty-numbers-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/i18n": minor
"@rocket.chat/ui-voip": minor
---

Introduces native desktop notifications for voice calls when using the desktop app.
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -2560,6 +2560,7 @@
"Incoming_Livechats": "Queued chats",
"Incoming_WebHook": "Incoming WebHook",
"Incoming_call": "Incoming call",
"Incoming_call_ellipsis": "Incoming call...",
"Incoming_call_from": "Incoming call from",
"Incoming_call_from__roomName__": "Incoming call from {{roomName}}",
"Incoming_call_transfer": "Incoming call transfer",
Expand Down
1 change: 1 addition & 0 deletions packages/ui-voip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"dependencies": {
"@rocket.chat/desktop-api": "workspace:^",
"@rocket.chat/emitter": "~0.31.25",
"@rocket.chat/media-signaling": "workspace:~",
"@tanstack/react-query": "~5.65.1",
Expand Down
7 changes: 7 additions & 0 deletions packages/ui-voip/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';

declare global {
interface Window {
RocketChatDesktop?: IRocketChatDesktop;
}
}
11 changes: 9 additions & 2 deletions packages/ui-voip/src/v2/MediaCallProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,26 @@ import {
useToastMessageDispatch,
useSetting,
} from '@rocket.chat/ui-contexts';
import { useCallback, useEffect } from 'react';
import { ReactNode, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';

import MediaCallContext, { PeerInfo } from './MediaCallContext';
import MediaCallWidget from './MediaCallWidget';
import TransferModal from './TransferModal';
import { useCallSounds } from './useCallSounds';
import { useDesktopNotifications } from './useDesktopNotifications';
import { getExtensionFromPeerInfo, useMediaSession } from './useMediaSession';
import { useMediaSessionInstance } from './useMediaSessionInstance';
import useMediaStream from './useMediaStream';
import { isValidTone, useTonePlayer } from './useTonePlayer';
import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt';

const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
type MediaCallProviderProps = {
children: ReactNode;
};

const MediaCallProvider = ({ children }: MediaCallProviderProps) => {
const user = useUser();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
Expand All @@ -37,6 +42,8 @@ const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
const instance = useMediaSessionInstance(userId ?? undefined);
const session = useMediaSession(instance);

useDesktopNotifications(session);

const [remoteStreamRefCallback, audioElement] = useMediaStream(instance);

const setOutputMediaDevice = useSetOutputMediaDevice();
Expand Down
6 changes: 3 additions & 3 deletions packages/ui-voip/src/v2/MediaCallWidget.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Meta, StoryFn, StoryObj } from '@storybook/react';

import { useMediaCallContext } from './MediaCallContext';
import MediaCallWidget from './MediaCallWidget';
import MediaCallProviderMock from './MockedMediaCallProvider';
import MockedMediaCallProvider from './MockedMediaCallProvider';

const mockedContexts = mockAppRoot()
.withTranslations('en', 'core', {
Expand All @@ -26,9 +26,9 @@ const meta = {
decorators: [
mockedContexts,
(Story, options) => (
<MediaCallProviderMock {...options.args}>
<MockedMediaCallProvider {...options.args}>
<Story />
</MediaCallProviderMock>
</MockedMediaCallProvider>
),
],
} satisfies Meta<typeof MediaCallWidget>;
Expand Down
26 changes: 14 additions & 12 deletions packages/ui-voip/src/v2/MockedMediaCallProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { useState } from 'react';
import { ReactNode, useState } from 'react';

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

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

const MediaCallProviderMock = ({
type MockedMediaCallProviderProps = {
children: ReactNode;
state?: State;
transferredBy?: string;
remoteMuted?: boolean;
remoteHeld?: boolean;
muted?: boolean;
held?: boolean;
};

const MockedMediaCallProvider = ({
children,
state = 'closed',
transferredBy = undefined,
remoteMuted = false,
remoteHeld = false,
muted = false,
held = false,
}: {
children: React.ReactNode;
state?: State;
transferredBy?: string;
remoteMuted?: boolean;
remoteHeld?: boolean;
muted?: boolean;
held?: boolean;
}) => {
}: MockedMediaCallProviderProps) => {
const [peerInfo, setPeerInfo] = useState<PeerInfo | undefined>({
displayName: 'John Doe',
userId: '1234567890',
Expand Down Expand Up @@ -147,4 +149,4 @@ const MediaCallProviderMock = ({
return <MediaCallContext.Provider value={contextValue}>{children}</MediaCallContext.Provider>;
};

export default MediaCallProviderMock;
export default MockedMediaCallProvider;
4 changes: 2 additions & 2 deletions packages/ui-voip/src/v2/components/Widget/Widget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Palette } from '@rocket.chat/fuselage';
import styled from '@rocket.chat/styled';
import { ComponentProps, useLayoutEffect } from 'react';
import { ComponentProps, ReactNode, useLayoutEffect } from 'react';
import { FocusScope } from 'react-aria';

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

type WidgetProps = {
children: React.ReactNode;
children: ReactNode;
} & ComponentProps<typeof WidgetBase>;

const Widget = ({ children, ...props }: WidgetProps) => {
Expand Down
75 changes: 75 additions & 0 deletions packages/ui-voip/src/v2/useDesktopNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';

import { PeerInfo } from './MediaCallContext';
import { SessionInfo } from './useMediaSessionInstance';
import { convertAvatarUrlToPng } from './utils/convertAvatarUrlToPng';

const getDisplayInfo = (peerInfo?: PeerInfo) => {
if (!peerInfo) {
return undefined;
}

if ('number' in peerInfo) {
return { title: peerInfo.number };
}
if ('displayName' in peerInfo) {
return { title: peerInfo.displayName, avatar: peerInfo.avatarUrl };
}
return undefined;
};

export const useDesktopNotifications = (sessionInfo: SessionInfo) => {
const previousCallId = useRef<string | undefined>(undefined);
const { t } = useTranslation();

const displayInfo = getDisplayInfo(sessionInfo.peerInfo);
useEffect(() => {
if (
typeof window.RocketChatDesktop?.dispatchCustomNotification !== 'function' ||
typeof window.RocketChatDesktop?.closeCustomNotification !== 'function'
) {
return;
}

let isMounted = true;

if (sessionInfo.state !== 'ringing') {
if (previousCallId.current) {
window.RocketChatDesktop.closeCustomNotification(previousCallId.current);
previousCallId.current = undefined;
}
return;
}

if (!displayInfo?.title) {
return;
}

const notifyDesktop = async () => {
const avatarAsPng = await convertAvatarUrlToPng(displayInfo.avatar);

if (!isMounted) {
return;
}

window.RocketChatDesktop?.dispatchCustomNotification({
type: 'voice',
id: sessionInfo.callId,
payload: {
title: displayInfo.title,
body: t('Incoming_call_ellipsis'),
avatar: avatarAsPng || undefined,
requireInteraction: true,
},
});
};

notifyDesktop();
previousCallId.current = sessionInfo.callId;

return () => {
isMounted = false;
};
}, [displayInfo?.avatar, displayInfo?.title, sessionInfo.callId, sessionInfo.state, t]);
};
4 changes: 2 additions & 2 deletions packages/ui-voip/src/v2/useKeypad.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Divider, Box, TextInput, Field, FieldRow } from '@rocket.chat/fuselage';
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { Keypad } from './components';

type UseKeypad = {
element: React.ReactNode;
element: ReactNode;
buttonProps: {
title: string;
onClick: () => void;
Expand Down
22 changes: 19 additions & 3 deletions packages/ui-voip/src/v2/useMediaSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SessionInfo } from './useMediaSessionInstance';

const defaultSessionInfo: SessionInfo = {
state: 'closed' as const,
callId: undefined,
connectionState: 'CONNECTING' as const,
peerInfo: undefined,
transferredBy: undefined,
Expand Down Expand Up @@ -48,7 +49,10 @@ export const getExtensionFromPeerInfo = (peerInfo: PeerInfo): string | undefined
return undefined;
};

const deriveWidgetStateFromCallState = (callState: CallState, callRole: CallRole): State | undefined => {
const deriveWidgetStateFromCallState = (
callState: CallState,
callRole: CallRole,
): Extract<State, 'ongoing' | 'ringing' | 'calling'> | undefined => {
switch (callState) {
case 'active':
case 'accepted':
Expand Down Expand Up @@ -143,7 +147,18 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession
return;
}

const { contact, transferredBy: callTransferredBy, state: callState, role, muted, held, hidden, remoteHeld, remoteMute } = mainCall;
const {
contact,
transferredBy: callTransferredBy,
state: callState,
role,
muted,
held,
hidden,
remoteHeld,
remoteMute,
callId,
} = mainCall;
const state = deriveWidgetStateFromCallState(callState, role);
const connectionState = deriveConnectionStateFromCallState(callState);

Expand All @@ -162,6 +177,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession
hidden,
remoteHeld,
remoteMuted: remoteMute,
callId,
},
});
return;
Expand Down Expand Up @@ -189,7 +205,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSession

dispatch({
type: 'instance_updated',
payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute },
payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute, callId },
});
};

Expand Down
2 changes: 2 additions & 0 deletions packages/ui-voip/src/v2/useMediaSessionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ interface BaseSession {

interface EmptySession extends BaseSession {
state: Extract<State, 'closed' | 'new'>;
callId: undefined;
}

interface CallSession extends BaseSession {
state: Extract<State, 'calling' | 'ringing' | 'ongoing'>;
callId: string;
peerInfo: PeerInfo;
}

Expand Down
36 changes: 36 additions & 0 deletions packages/ui-voip/src/v2/utils/convertAvatarUrlToPng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const convertAvatarUrlToPng = (avatarUrl: string | undefined): Promise<string> => {
return new Promise((resolve) => {
if (!avatarUrl) {
resolve('');
return;
}

const image = new Image();

const onLoad = (): void => {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');

if (!context) {
resolve('');
return;
}

context.drawImage(image, 0, 0);
try {
resolve(canvas.toDataURL('image/png'));
} catch (e) {
resolve('');
}
};

const onError = (): void => resolve('');

image.onload = onLoad;
image.onerror = onError;
image.crossOrigin = 'anonymous';
image.src = avatarUrl;
});
};
6 changes: 3 additions & 3 deletions packages/ui-voip/src/v2/views/IncomingCall.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';

import IncomingCall from './IncomingCall';
import MediaCallProviderMock from '../MockedMediaCallProvider';
import MockedMediaCallProvider from '../MockedMediaCallProvider';

const mockedContexts = mockAppRoot()
.withTranslations('en', 'core', {
Expand All @@ -18,9 +18,9 @@ export default {
decorators: [
mockedContexts,
(Story) => (
<MediaCallProviderMock>
<MockedMediaCallProvider>
<Story />
</MediaCallProviderMock>
</MockedMediaCallProvider>
),
],
} satisfies Meta<typeof IncomingCall>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';

import IncomingCallTransfer from './IncomingCallTransfer';
import MediaCallProviderMock from '../MockedMediaCallProvider';
import MockedMediaCallProvider from '../MockedMediaCallProvider';

const mockedContexts = mockAppRoot()
.withTranslations('en', 'core', {
Expand All @@ -19,9 +19,9 @@ export default {
decorators: [
mockedContexts,
(Story) => (
<MediaCallProviderMock transferredBy='Jason'>
<MockedMediaCallProvider transferredBy='Jason'>
<Story />
</MediaCallProviderMock>
</MockedMediaCallProvider>
),
],
} satisfies Meta<typeof IncomingCallTransfer>;
Expand Down
Loading
Loading