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,19 @@
import type { Decorator } from '@storybook/react';
const DefaultBodySize: Decorator = function DarkModeDecorator(Story) {
return (
<>
<style>
{`
body {
min-width: 1024px;
min-height: 1280px;
}
`}
</style>
<Story />
</>
);
};
export default DefaultBodySize;

View File

@@ -0,0 +1,3 @@
import baseConfig from '@rocket.chat/storybook-config/main';
export default baseConfig();

View File

@@ -0,0 +1,13 @@
import { parameters, decorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
import DefaultBodySize from './DefaultBodySize';
const preview: Preview = {
parameters: {
...parameters,
},
decorators: [...decorators, DefaultBodySize],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import client from '@rocket.chat/jest-presets/client';
import type { Config } from 'jest';
export default {
preset: client.preset,
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
moduleNameMapper: {
'^react($|/.+)': '<rootDir>/../../node_modules/react$1',
'^react-dom($|/.+)': '<rootDir>/../../node_modules/react-dom$1',
'^react-i18next($|/.+)': '<rootDir>/../../node_modules/react-i18next$1',
},
} satisfies Config;

View File

@@ -0,0 +1,88 @@
{
"name": "@rocket.chat/ui-voip",
"version": "24.0.0",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json && yarn build:post",
"build:post": "node --no-warnings dist/generate-landing-view.js && rm -f dist/generate-landing-view.*",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"storybook": "storybook dev -p 6006",
"test": "yarn testunit",
"test-storybook": "npx concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --ci\" \"npx wait-on tcp:127.0.0.1:6006 && yarn exec test-storybook\"",
"testunit": "TZ=UTC jest",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"dependencies": {
"@rocket.chat/desktop-api": "workspace:^",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/media-signaling": "workspace:~",
"@tanstack/react-query": "~5.65.1",
"react-i18next": "~13.2.2"
},
"devDependencies": {
"@playwright/test": "~1.52.0",
"@react-spectrum/test-utils": "~1.0.0-alpha.8",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/fuselage": "^0.89.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/fuselage-ui-kit": "workspace:^",
"@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/mock-providers": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/styled": "~0.34.1",
"@rocket.chat/tsconfig": "workspace:*",
"@rocket.chat/ui-avatar": "workspace:^",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@rocket.chat/ui-kit": "workspace:^",
"@storybook/react": "^9.1.20",
"@storybook/test-runner": "^0.22.1",
"@testing-library/dom": "~10.4.1",
"@testing-library/react": "~16.3.3",
"@testing-library/user-event": "~14.6.6",
"@types/jest": "~30.0.0",
"@types/jest-axe": "~3.5.9",
"@types/node": "~24.13.2",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"date-fns": "~4.1.0",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"jest": "~30.2.0",
"jest-axe": "~10.0.0",
"jsdom": "^26.1.0",
"react": "~19.2.8",
"react-dom": "~19.2.8",
"react-virtuoso": "~4.18.12",
"storybook": "^9.1.20",
"typescript": "~5.9.3"
},
"peerDependencies": {
"@rocket.chat/css-in-js": "*",
"@rocket.chat/fuselage": "*",
"@rocket.chat/fuselage-hooks": "*",
"@rocket.chat/fuselage-ui-kit": "workspace:^",
"@rocket.chat/icons": "*",
"@rocket.chat/styled": "*",
"@rocket.chat/ui-avatar": "workspace:^",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"date-fns": "*",
"react": "*",
"react-aria": "*",
"react-dom": "*"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,36 @@
import { Icon, IconButton } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { ComponentProps, ReactElement } from 'react';
import { forwardRef } from 'react';
export type ActionButtonProps = {
label: string;
/** An icon by name, or something rendered in its place — a voice-activity indicator, say. */
icon: Keys | ReactElement;
/** Renders the larger variant used by the conference UI. The widget keeps its original size by default. */
large?: boolean;
disabled?: boolean;
onClick?: () => void;
} & Omit<ComponentProps<typeof IconButton>, 'icon' | 'aria-label' | 'disabled' | 'onClick'>;
const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(function ActionButton(
{ disabled, label, icon, onClick, title, secondary = true, large = false, ...props },
ref,
) {
return (
<IconButton
label={label}
{...(large ? { large: true } : { medium: true })}
secondary={secondary}
icon={typeof icon === 'string' ? <Icon size={large ? 20 : 16} name={icon} /> : icon}
title={title || label}
aria-label={label}
disabled={disabled}
onClick={onClick}
{...props}
ref={ref}
/>
);
});
export default ActionButton;

View File

@@ -0,0 +1,57 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import ActionStrip from './ActionStrip';
import ActionButton from '../ActionButton';
import Timer from '../Timer';
import ToggleButton from '../ToggleButton';
import ActionToggleChat from './ActionToggleChat';
export default {
component: ActionStrip,
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
Mute: 'Mute',
Unmute: 'Unmute',
Hold: 'Hold',
Resume: 'Resume',
Show_chat: 'Show chat',
Hide_chat: 'Hide chat',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof ActionStrip>;
const NOOP = () => undefined;
export const ActionStripStory: StoryObj<typeof ActionStrip> = {
render: (args) => {
const { t } = useTranslation();
const [pressed, setPressed] = useState(false);
return (
<ActionStrip leftSlot={<Timer />} rightSlot={<ActionToggleChat pressed={pressed} onClick={() => setPressed(!pressed)} />} {...args}>
<ToggleButton label={t('Mute')} icons={['mic', 'mic-off']} titles={[t('Mute'), t('Unmute')]} pressed={false} onToggle={NOOP} />
<ToggleButton
label={t('Hold')}
icons={['pause-shape-unfilled', 'pause-shape-unfilled']}
titles={[t('Hold'), t('Resume')]}
pressed={false}
onToggle={NOOP}
/>
<ToggleButton
label={t('Screen_sharing')}
icons={['computer', 'computer']}
titles={[t('Screen_sharing'), t('Screen_sharing_off')]}
pressed={false}
onToggle={NOOP}
/>
<ActionButton disabled={false} label={t('Forward')} icon='arrow-forward' onClick={NOOP} />
<ActionButton label={t('Voice_call__user__hangup', { user: 'John Doe' })} icon='phone-off' danger onClick={NOOP} />
{/* <DevicePicker /> */}
</ActionStrip>
);
},
};

View File

@@ -0,0 +1,40 @@
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export const ACTION_STRIP_TOTAL_HEIGHT = 52;
export type ActionStripProps = {
children: ReactNode;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
const ActionStrip = ({ children, leftSlot, rightSlot }: ActionStripProps) => {
return (
<Box
display='flex'
flexDirection='row'
justifyContent='space-between'
flexShrink={0}
width='full'
alignItems='center'
height={ACTION_STRIP_TOTAL_HEIGHT}
backgroundColor='surface-light'
borderBlock='1px solid'
borderColor='stroke-extra-light'
paddingInline={20}
>
<Box is='span' display='flex' justifyContent='start' alignItems='center' flexGrow={0} flexBasis='20%'>
{leftSlot}
</Box>
<Box is='span' display='flex' justifyContent='center' alignItems='center' flexGrow={1} flexBasis='60%'>
<ButtonGroup large>{children}</ButtonGroup>
</Box>
<Box is='span' display='flex' justifyContent='end' alignItems='center' flexGrow={0} flexBasis='20%'>
{rightSlot}
</Box>
</Box>
);
};
export default ActionStrip;

View File

@@ -0,0 +1,32 @@
import { Badge, Box, Button } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
export type ActionToggleChatProps = {
pressed: boolean;
onClick: () => void;
badgeCount?: number;
badgeVariant?: ComponentProps<typeof Badge>['variant'];
};
const ActionToggleChat = ({ pressed, onClick, badgeCount = 0, badgeVariant = 'secondary' }: ActionToggleChatProps) => {
const { t } = useTranslation();
const icon = pressed ? 'chevron-down' : 'chevron-up';
const label = pressed ? t('Hide_chat') : t('Show_chat');
// The badge is decorative to assistive tech: the count travels in the button's own name instead, so it is
// announced once, with the control it belongs to, rather than as a stray number beside it.
return (
<Box position='relative' display='inline-flex'>
<Button medium onClick={onClick} icon={icon} aria-label={badgeCount > 0 ? `${label}, ${badgeCount} ${t('Unread')}` : undefined}>
{label}
</Button>
{badgeCount > 0 && (
<Box position='absolute' insetBlockStart={-4} insetInlineEnd={-4} pointerEvents='none' aria-hidden>
<Badge variant={badgeVariant}>{badgeCount}</Badge>
</Box>
)}
</Box>
);
};
export default ActionToggleChat;

View File

@@ -0,0 +1,2 @@
export { default as ActionStrip } from './ActionStrip';
export { default as ActionToggleChat } from './ActionToggleChat';

View File

@@ -0,0 +1,26 @@
import { Box, Icon, FramedIcon } from '@rocket.chat/fuselage';
import type { CallHistoryExternalContact } from '../definitions';
export type CallHistoryExternalUserProps = {
contact: CallHistoryExternalContact;
showIcon?: boolean;
};
const CallHistoryExternalUser = ({ contact: { number }, showIcon = true }: CallHistoryExternalUserProps) => {
return (
<Box display='flex' flexDirection='row' alignItems='center'>
<Box marginInlineEnd={8}>
<FramedIcon icon='user' size={28} />
</Box>
{showIcon && (
<Box marginInlineEnd={8}>
<Icon name='phone' size={20} />
</Box>
)}
<Box>{number}</Box>
</Box>
);
};
export default CallHistoryExternalUser;

View File

@@ -0,0 +1,36 @@
import { Box, Icon, Avatar, StatusBullet } from '@rocket.chat/fuselage';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useUserAvatarPath, useUserPresence, useUserCard } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { CallHistoryInternalContact } from '../definitions';
export type CallHistoryInternalUserProps = {
contact: CallHistoryInternalContact;
};
const CallHistoryInternalUser = ({ contact: { username, name, _id } }: CallHistoryInternalUserProps) => {
const getUserAvatarPath = useUserAvatarPath();
const avatarUrl = useMemo(() => {
return getUserAvatarPath({ username });
}, [username, getUserAvatarPath]);
const displayName = useUserDisplayName({ username, name });
const userStatus = useUserPresence(_id);
const { triggerProps, openUserCard } = useUserCard();
return (
<Box display='flex' flexDirection='row' alignItems='center' role='button' onClick={(e) => openUserCard(e, username)} {...triggerProps}>
<Box marginInlineEnd={8}>{avatarUrl ? <Avatar url={avatarUrl} size='x28' /> : <Icon name='user' size='x28' />}</Box>
<Box marginInlineEnd={8}>
<StatusBullet status={userStatus?.status || 'loading'} size='small' />
</Box>
<Box>{displayName}</Box>
</Box>
);
};
export default CallHistoryInternalUser;

View File

@@ -0,0 +1,16 @@
import { Box, FramedIcon } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
const CallHistoryUnknownUser = () => {
const { t } = useTranslation();
return (
<Box display='flex' flexDirection='row' alignItems='center'>
<Box marginInlineEnd={8}>
<FramedIcon icon='warning' size={28} />
</Box>
<Box>{t('Unknown')}</Box>
</Box>
);
};
export default CallHistoryUnknownUser;

View File

@@ -0,0 +1,22 @@
import CallHistoryExternalUser from './CallHistoryExternalUser';
import CallHistoryInternalUser from './CallHistoryInternalUser';
import CallHistoryUnknownUser from './CallHistoryUnknownUser';
import { isCallHistoryExternalContact, isCallHistoryInternalContact, type CallHistoryContact } from '../definitions';
export type CallHistoryUserProps = {
contact: CallHistoryContact;
};
const CallHistoryUser = ({ contact }: CallHistoryUserProps) => {
if (isCallHistoryInternalContact(contact)) {
return <CallHistoryInternalUser contact={contact} />;
}
if (isCallHistoryExternalContact(contact)) {
return <CallHistoryExternalUser showIcon={false} contact={contact} />;
}
return <CallHistoryUnknownUser />;
};
export default CallHistoryUser;

View File

@@ -0,0 +1,87 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
const styles = css`
box-shadow:
0px 0px 1px 0px ${Palette.shadow['shadow-elevation-2x'].toString()},
0px 0px 12px 0px ${Palette.shadow['shadow-elevation-2y'].toString()};
&:hover .rcx-card-slot--onHover {
display: flex;
}
`;
export const CARD_HEIGHT = 180;
export const CARD_MIN_WIDTH = CARD_HEIGHT;
const CARD_MAX_WIDTH = 320;
export const CARD_MARGIN = 2;
export const CARD_TOTAL_HEIGHT = CARD_HEIGHT + CARD_MARGIN * 2;
export const CARD_TOTAL_WIDTH = CARD_MAX_WIDTH + CARD_MARGIN * 2;
export type CardProps = {
children: ReactNode;
title?: string;
maxWidth?: string | number;
maxHeight?: string | number;
width?: string | number;
height?: string | number;
flexGrow?: number;
flexShrink?: number;
minHeight?: number;
variant?: 'highlighted' | 'default';
};
const highlightedBorderProps = {
border: '4px solid',
borderColor: 'stroke-extra-light-error',
} as const;
const defaultBorderProps = {
border: '1px solid',
borderColor: 'stroke-medium',
} as const;
const Card = ({
children,
title,
maxWidth = CARD_MAX_WIDTH, // 4:3
maxHeight = CARD_HEIGHT, // 4:3
minHeight = CARD_HEIGHT,
width,
height = CARD_HEIGHT,
flexGrow = 0,
flexShrink = 1,
variant = 'default',
}: CardProps) => {
const borderProps = variant === 'highlighted' ? highlightedBorderProps : defaultBorderProps;
return (
<Box
position='relative'
display='flex'
flexGrow={flexGrow}
flexShrink={flexShrink}
overflow='hidden'
alignItems='center'
title={title}
borderRadius='4px'
className={styles}
backgroundColor='surface-light'
maxHeight={maxHeight}
maxWidth={maxWidth}
width={width}
height={height}
minWidth={CARD_MIN_WIDTH}
minHeight={minHeight}
margin={CARD_MARGIN}
{...borderProps}
>
{children}
</Box>
);
};
export default Card;

View File

@@ -0,0 +1,70 @@
import { Box } from '@rocket.chat/fuselage';
import type { Meta, StoryObj } from '@storybook/react';
import CardList from './CardList';
import CardListSection from './CardListSection';
import { PeerCard } from './PeerCard';
import { StreamCard } from './StreamCard';
const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`;
export default {
component: CardList,
decorators: [
(Story) => (
<Box width={700} height={400} border='1px solid' borderColor='stroke-light' overflow='hidden'>
<CardListSection>
<Story />
</CardListSection>
</Box>
),
],
} satisfies Meta<typeof CardList>;
export const CardListStory: StoryObj<typeof CardList> = {
render: (args) => (
<CardList {...args}>
<PeerCard displayName='John Doe 0' avatarUrl={avatarUrl} muted={false} held={true} />
<PeerCard displayName='John Doe 1' avatarUrl={avatarUrl} muted={true} held={false} />
<PeerCard displayName='John Doe 2' avatarUrl={avatarUrl} muted={true} held={true} />
<PeerCard displayName='John Doe 3' avatarUrl={avatarUrl} muted={false} held={false} />
</CardList>
),
};
export const StreamCardListStory: StoryObj<typeof CardList> = {
render: (args) => (
<CardList {...args}>
<StreamCard own focused autoHeight onClickFocusStream={() => undefined} onClickStopSharing={() => undefined}>
<Box width='100%' height='100%' minHeight={120}>
test
</Box>
</StreamCard>
<StreamCard onClickFocusStream={() => undefined} onClickStopSharing={() => undefined}>
<Box width='100%' height='100%' minHeight={120}>
test
</Box>
</StreamCard>
<StreamCard own onClickFocusStream={() => undefined} onClickStopSharing={() => undefined}>
<Box width='100%' height='100%' minHeight={120}>
test
</Box>
</StreamCard>
<StreamCard onClickFocusStream={() => undefined} onClickStopSharing={() => undefined}>
<Box width='100%' height='100%' minHeight={120}>
test
</Box>
</StreamCard>
</CardList>
),
};

View File

@@ -0,0 +1,41 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type CardListProps = {
children: ReactNode;
shouldWrapCards?: boolean;
direction?: 'row' | 'column';
height?: number;
marginInline?: 'auto' | number;
overflow?: 'hidden' | 'scroll' | 'auto';
autoMargin?: boolean;
};
const CardList = ({
children,
shouldWrapCards,
direction = 'row',
height,
marginInline = 'auto',
overflow,
autoMargin = false,
}: CardListProps) => {
return (
<Box
display='flex'
marginBlock={autoMargin ? 'auto' : undefined}
marginInline={marginInline}
flexDirection={direction}
flexWrap={shouldWrapCards ? 'wrap' : 'nowrap'}
flexGrow={1}
justifyContent='center'
alignItems='center'
height={height}
overflow={overflow}
>
{children}
</Box>
);
};
export default CardList;

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import CardList from './CardList';
import CardListPinned from './CardListPinned';
export type CardListContainerProps = {
children: ReactNode;
focusedCard?: ReactNode;
shouldWrapCards?: boolean;
};
const CardListContainer = ({ children, focusedCard, shouldWrapCards }: CardListContainerProps) => {
if (focusedCard) {
return (
<CardListPinned focusedCard={focusedCard} flexDirection='row'>
{children}
</CardListPinned>
);
}
return (
<CardList shouldWrapCards={shouldWrapCards} marginInline='auto'>
{children}
</CardList>
);
};
export default CardListContainer;

View File

@@ -0,0 +1,59 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { CustomScrollbars } from '@rocket.chat/ui-client';
import type { CSSProperties, ReactNode } from 'react';
import { CARD_MARGIN, CARD_MIN_WIDTH } from './Card';
import CardListContainer from './CardList';
export type CardListPinnedProps = {
children: ReactNode;
focusedCard: ReactNode;
flexDirection?: CSSProperties['flexDirection'];
};
// This is a workaround to center the card list when it's not overflowing yet.
const scrollbarContainerStyle = css`
[data-overlayscrollbars-viewport] {
display: flex;
}
`;
const CardListPinned = ({ children, focusedCard, flexDirection = 'row' }: CardListPinnedProps) => {
return (
<Box display='flex' flexDirection={flexDirection} justifyContent='center' alignItems='center' height='100%' width='100%'>
<Box
display='flex'
flexGrow={1}
flexShrink={1}
width='100%'
height='100%'
alignItems='center'
justifyContent='center'
flexDirection='column'
>
{focusedCard}
</Box>
<Box
display='flex'
overflow='auto'
justifyContent='stretch'
alignItems='start'
flexDirection='row'
height='100%'
flexShrink={0}
width={CARD_MIN_WIDTH + CARD_MARGIN * 2}
className={scrollbarContainerStyle}
>
<CustomScrollbars>
<CardListContainer autoMargin overflow='hidden' direction={flexDirection === 'row' ? 'column' : 'row'}>
{children}
</CardListContainer>
</CustomScrollbars>
</Box>
</Box>
);
};
export default CardListPinned;

View File

@@ -0,0 +1,27 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type CardListSectionProps = {
children: ReactNode;
};
export const SECTION_MAX_HEIGHT = 50;
const CardListSection = ({ children }: CardListSectionProps) => {
return (
<Box
display='flex'
flexGrow={1}
flexShrink={1}
overflow='auto'
justifyContent='stretch'
alignItems='center'
padding={2}
maxHeight='full'
>
{children}
</Box>
);
};
export default CardListSection;

View File

@@ -0,0 +1,60 @@
import { Box, Palette } from '@rocket.chat/fuselage';
import styled from '@rocket.chat/styled';
import type { ReactNode } from 'react';
const slotPositionStyles = {
topLeft: 'top: 0; left: 0;',
topRight: 'top: 0; right: 0;',
bottomLeft: 'bottom: 0; left: 0;',
bottomRight: 'bottom: 0; right: 0;',
middle: 'top: 50%; left: 50%; transform: translate(-50%, -50%);',
};
export type SlotPosition = keyof typeof slotPositionStyles;
export type CardSlotProps = {
position: SlotPosition;
margin?: number;
children: ReactNode;
variant?: 'default' | 'transparent';
showOnHover?: boolean;
};
const CardSlotBase = styled(
'div',
({ position: _position, margin: _margin, variant: _variant, showOnHover: _showOnHover, ...props }: CardSlotProps) => props,
)`
position: absolute;
display: ${({ showOnHover }) => (showOnHover ? 'none' : 'flex')};
flex-direction: row;
align-items: center;
justify-content: center;
margin: ${({ margin }) => (typeof margin === 'number' ? `${margin}px` : '4px')};
height: 24px;
min-width: 24px;
padding-inline: 8px;
${({ position }) => slotPositionStyles[position]}
border-radius: 4px;
${({ variant }) => (variant === 'default' ? `background-color: ${Palette.surface['surface-tint'].toString()};` : '')}
color: ${Palette.text['font-default'].toString()};
overflow: hidden;
z-index: 3;
`;
const CardSlot = ({ children, position, margin, variant = 'default', showOnHover = false }: CardSlotProps) => {
return (
<CardSlotBase
position={position}
margin={margin}
variant={variant}
showOnHover={showOnHover}
className={showOnHover ? 'rcx-card-slot--onHover' : undefined}
>
<Box is='span' fontScale='c2'>
{children}
</Box>
</CardSlotBase>
);
};
export default CardSlot;

View File

@@ -0,0 +1,16 @@
import { Box, Margins } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type CardWidgetContainerProps = {
children: ReactNode;
};
const CardWidgetContainer = ({ children }: CardWidgetContainerProps) => {
return (
<Box marginBlock={-8}>
<Margins block={8}>{children}</Margins>
</Box>
);
};
export default CardWidgetContainer;

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import Card from './Card';
import CardSlot from './CardSlot';
import { PeerCard, PeerCardSlot } from './PeerCard';
export default {
component: Card,
args: {
title: 'Generic Card',
children: 'This is the content.',
},
} satisfies Meta<typeof Card>;
const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`;
export const CardStory: StoryObj<typeof Card> = {
render: (args) => (
<Card {...args}>
<PeerCardSlot displayName='John Doe' muted={false} held={true} />
<CardSlot position='bottomRight'>
<span>Bottom Right</span>
</CardSlot>
<CardSlot position='topLeft'>
<span>Top Left</span>
</CardSlot>
<CardSlot position='topRight'>
<span>Top Right</span>
</CardSlot>
</Card>
),
};
export const PeerCardStory: StoryObj<typeof Card> = {
render: (args) => <PeerCard displayName='John Doe' avatarUrl={avatarUrl} muted={false} held={false} {...args} />,
};

View File

@@ -0,0 +1,32 @@
import { Avatar, Box, Icon } from '@rocket.chat/fuselage';
import Card from '../Card';
import PeerCardSlot from './PeerCardSlot';
export type PeerCardProps = {
displayName: string;
avatarUrl?: string;
muted: boolean;
held: boolean;
};
const PeerCard = ({ displayName, avatarUrl, muted, held }: PeerCardProps) => {
return (
<Card flexGrow={0} flexShrink={0}>
<Box
display='flex'
alignItems='center'
justifyItems='center'
justifyContent='center'
alignContent='center'
width='100%'
height='100%'
>
<PeerCardSlot muted={muted} held={held} displayName={displayName} />
<Box marginBlockEnd={8}>{avatarUrl ? <Avatar url={avatarUrl} size='x48' /> : <Icon name='user' size='x48' />}</Box>
</Box>
</Card>
);
};
export default PeerCard;

View File

@@ -0,0 +1,21 @@
import { Icon } from '@rocket.chat/fuselage';
import CardSlot from '../CardSlot';
export type PeerCardSlotProps = {
muted: boolean;
held: boolean;
displayName: string;
};
const PeerCardSlot = ({ muted, held, displayName }: PeerCardSlotProps) => {
return (
<CardSlot position='bottomLeft'>
{displayName}
{muted && <Icon name='mic-off' size='x12' color='danger' marginInlineStart={4} />}
{held && <Icon name='pause-shape-unfilled' size='x12' color='danger' marginInlineStart={4} />}
</CardSlot>
);
};
export default PeerCardSlot;

View File

@@ -0,0 +1,2 @@
export { default as PeerCard } from './PeerCard';
export { default as PeerCardSlot } from './PeerCardSlot';

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import Card from '../Card';
import StreamCardOpenInRoomButton from './StreamCardOpenInRoom';
import StreamCardPin from './StreamCardPin';
import StreamCardStopSharingButton from './StreamCardStopSharingButton';
export type StreamCardProps = {
children: ReactNode;
own?: boolean;
onClickFocusStream?: () => void;
onClickStopSharing?: () => void;
onClickOpenInRoom?: () => void;
focused?: boolean;
autoHeight?: boolean;
maxHeight?: number;
showStopSharingOnHover?: boolean;
};
const alternateSizeProps = {
maxWidth: '100%',
maxHeight: '100%',
height: undefined,
width: undefined,
};
const StreamCard = ({
children,
own,
onClickFocusStream,
onClickStopSharing,
onClickOpenInRoom,
focused,
autoHeight,
maxHeight,
showStopSharingOnHover = false,
}: StreamCardProps) => {
return (
<Card
variant={own ? 'highlighted' : 'default'}
{...(focused ? alternateSizeProps : {})}
height={focused || autoHeight ? 'fit-content' : undefined}
minHeight={autoHeight ? 0 : undefined}
maxHeight={maxHeight || '100%'}
>
{onClickFocusStream && <StreamCardPin focused={focused} onClick={onClickFocusStream} position='bottomRight' />}
{own && onClickStopSharing && <StreamCardStopSharingButton onClick={onClickStopSharing} showOnHover={showStopSharingOnHover} />}
{onClickOpenInRoom && <StreamCardOpenInRoomButton onClick={onClickOpenInRoom} showOnHover={true} />}
{children}
</Card>
);
};
export default StreamCard;

View File

@@ -0,0 +1,22 @@
import { Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import CardSlotContainer from '../CardSlot';
export type StreamCardOpenInRoomButtonProps = {
onClick: () => void;
showOnHover?: boolean;
};
const StreamCardOpenInRoomButton = ({ onClick, showOnHover = false }: StreamCardOpenInRoomButtonProps) => {
const { t } = useTranslation();
return (
<CardSlotContainer position='middle' variant='transparent' showOnHover={showOnHover} margin={0}>
<Button primary small icon='arrow-expand' onClick={onClick}>
{t('Open_in_room')}
</Button>
</CardSlotContainer>
);
};
export default StreamCardOpenInRoomButton;

View File

@@ -0,0 +1,22 @@
import { IconButton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import CardSlotContainer from '../CardSlot';
import type { SlotPosition } from '../CardSlot';
export type CardSlotPinProps = {
onClick: () => void;
focused?: boolean;
position?: SlotPosition;
};
const CardSlotPin = ({ focused, onClick, position = 'bottomRight' }: CardSlotPinProps) => {
const { t } = useTranslation();
return (
<CardSlotContainer position={position} variant='transparent'>
<IconButton tiny secondary={true} icon={focused ? 'pin-filled' : 'pin'} onClick={onClick} title={focused ? t('Unpin') : t('Pin')} />
</CardSlotContainer>
);
};
export default CardSlotPin;

View File

@@ -0,0 +1,22 @@
import { Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import CardSlotContainer from '../CardSlot';
export type CardSlotStopSharingProps = {
onClick: () => void;
showOnHover?: boolean;
};
const CardSlotStopSharing = ({ onClick, showOnHover = false }: CardSlotStopSharingProps) => {
const { t } = useTranslation();
return (
<CardSlotContainer position='middle' variant='transparent' showOnHover={showOnHover} margin={0}>
<Button danger small icon='desktop-cross' onClick={onClick}>
{t('Stop_sharing')}
</Button>
</CardSlotContainer>
);
};
export default CardSlotStopSharing;

View File

@@ -0,0 +1,3 @@
export { default as StreamCard } from './StreamCard';
export { default as StreamCardPin } from './StreamCardPin';
export { default as StreamCardStopSharingButton } from './StreamCardStopSharingButton';

View File

@@ -0,0 +1,14 @@
export * from './PeerCard';
export * from './StreamCard';
export { default as Card } from './Card';
export { default as CardWidgetContainer } from './CardWidgetContainer';
export { default as CardSlot } from './CardSlot';
export { default as CardList } from './CardList';
export { default as CardListPinned } from './CardListPinned';
export { default as CardListContainer } from './CardListContainer';
export { default as CardListSection, SECTION_MAX_HEIGHT as CARD_LIST_SECTION_MAX_HEIGHT } from './CardListSection';
export { useShouldWrapCards } from './useShouldWrapCards';

View File

@@ -0,0 +1,13 @@
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { CARD_TOTAL_HEIGHT } from './Card';
import { SECTION_MAX_HEIGHT } from './CardListSection';
import { ACTION_STRIP_TOTAL_HEIGHT } from '../Actions/ActionStrip';
// The minimun height that will fit 2 cards on top of eachother
export const SECTION_MIN_HEIGHT_WRAP_COLLAPSED = (CARD_TOTAL_HEIGHT * 2 + ACTION_STRIP_TOTAL_HEIGHT) / (SECTION_MAX_HEIGHT / 100);
export const useShouldWrapCards = (showChat: boolean, containerHeight: number) => {
const shouldWrapCollapsed = useMediaQuery(`(min-height: ${SECTION_MIN_HEIGHT_WRAP_COLLAPSED}px)`);
return showChat ? shouldWrapCollapsed : containerHeight > CARD_TOTAL_HEIGHT * 2 + ACTION_STRIP_TOTAL_HEIGHT;
};

View File

@@ -0,0 +1,160 @@
import { Box, RadioButton } from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useAvailableDevices, useSelectedDevices } from '@rocket.chat/ui-contexts';
import type { ComponentProps, MouseEvent } from 'react';
import { forwardRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionButton } from '.';
import { useMediaCallView } from '../context/MediaCallViewContext';
import { useDevicePermissionPrompt2, stopTracks } from '../hooks/useDevicePermissionPrompt';
export type DevicePickerButtonProps = {
secondary?: boolean;
small?: boolean;
} & Omit<ComponentProps<typeof ActionButton>, 'label' | 'icon'>;
// GenericMenu for some reason passes `small: true` when the button is disabled (??).
// so this is just a wrapper to stop that from happening.
const DevicePickerButton = forwardRef<HTMLButtonElement, DevicePickerButtonProps>(function DevicePickerButton(
{ secondary = false, small: _small, ...props },
ref,
) {
return <ActionButton secondary={secondary} flexShrink={1} flexGrow={0} {...props} label='customize' icon='customize' ref={ref} />;
});
const getDefaultDeviceItem = (label: string, type: 'input' | 'output') => ({
content: (
<Box is='span' title={label} fontSize={14}>
{label}
</Box>
),
addon: <RadioButton onChange={() => undefined} checked={true} disabled />,
id: `default-${type}`,
});
export type DevicePickerProps = { secondary?: boolean; className?: string };
// eslint-disable-next-line react/no-multi-comp
const DevicePicker = ({ secondary = false, className }: DevicePickerProps) => {
const { t } = useTranslation();
const { onDeviceChange } = useMediaCallView();
const availableDevices = useAvailableDevices();
const selectedAudioDevices = useSelectedDevices();
const availableInputDevice =
availableDevices?.audioInput?.map<GenericMenuItemProps>((device) => {
if (!device.id || !device.label) {
return getDefaultDeviceItem(t('Default'), 'input');
}
return {
id: `${device.id}-input`,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: <RadioButton checked={device.id === selectedAudioDevices?.audioInput?.id} />,
};
}) || [];
const availableOutputDevice =
availableDevices?.audioOutput?.map<GenericMenuItemProps>((device) => {
if (!device.id || !device.label) {
return getDefaultDeviceItem(t('Default'), 'output');
}
return {
id: `${device.id}-output`,
content: (
<Box is='span' title={device.label} fontSize={14}>
{device.label}
</Box>
),
addon: <RadioButton checked={device.id === selectedAudioDevices?.audioOutput?.id} />,
onClick(e?: MouseEvent<HTMLElement>) {
e?.preventDefault();
e?.stopPropagation();
},
};
}) || [];
const micSection = {
title: t('Microphone'),
items: availableInputDevice,
};
const speakerSection = {
title: t('Speaker'),
items: availableOutputDevice,
};
const disabled = availableOutputDevice.length === 0 && availableInputDevice.length === 0;
const [isOpen, setIsOpen] = useSafely(useState(false));
const requestPermission = useDevicePermissionPrompt2();
const onOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
setIsOpen(false);
return;
}
void requestPermission({
actionType: 'device-change',
}).then((stream) => {
stopTracks(stream);
setIsOpen(true);
});
},
[requestPermission, setIsOpen],
);
return (
<GenericMenu
title={disabled ? t('Device_settings_not_supported_by_browser') : t('Device_settings_lowercase')}
sections={[micSection, speakerSection]}
disabled={disabled}
placement='top-end'
selectionMode='multiple'
isOpen={isOpen}
onOpenChange={onOpenChange}
className={className}
onAction={(deviceId) => {
if (typeof deviceId !== 'string') {
return;
}
if (deviceId.includes('-input')) {
const id = deviceId.replace('-input', '');
const device = availableDevices?.audioInput?.find((device) => device.id === id);
if (device) {
onDeviceChange(device);
}
return;
}
if (deviceId.includes('-output')) {
const id = deviceId.replace('-output', '');
const device = availableDevices?.audioOutput?.find((device) => device.id === id);
if (device) {
onDeviceChange(device);
}
return;
}
console.warn('Device Picker - Failed to select device: Invalid deviceId', deviceId);
}}
button={<DevicePickerButton secondary={secondary} tiny={!secondary} />}
/>
);
};
export default DevicePicker;

View File

@@ -0,0 +1,49 @@
import { useLongPress, usePress } from '@react-aria/interactions';
import { mergeProps } from '@react-aria/utils';
import { css } from '@rocket.chat/css-in-js';
import { Box, Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type KeyProps = {
primaryKey: string;
alternativeKey?: string;
longPressKey?: string;
onLongKeyPress: (digit: string) => void;
onKeyPress: (digit: string) => void;
large?: boolean;
};
const dialPadButtonClass = css`
> .rcx-button--content {
display: flex;
flex-direction: column;
}
`;
const Key = ({ primaryKey, alternativeKey, longPressKey, onLongKeyPress, onKeyPress, large }: KeyProps) => {
const { t } = useTranslation();
const { longPressProps } = useLongPress({
accessibilityDescription: `${t(`Long_press_to_do_x`, { action: longPressKey })}`,
onLongPress: () => longPressKey && onLongKeyPress(longPressKey),
});
const { pressProps } = usePress({
onPress: () => onKeyPress(primaryKey),
});
const buttonProps = longPressKey ? mergeProps(pressProps, longPressProps) : pressProps;
return (
<Button minWidth={52} width={52} height={40} padding={0} margin={4} {...buttonProps} className={dialPadButtonClass} borderRadius={8}>
<Box is='span' fontScale={large ? 'h1' : 'p1'} fontWeight={large ? 300 : 400} marginBlock={-4}>
{primaryKey}
</Box>
<Box is='span' fontScale='c1' color='hint' aria-hidden minHeight={16} letterSpacing='0.15em'>
{alternativeKey}
</Box>
</Button>
);
};
export default Key;

View File

@@ -0,0 +1,21 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './Keypad.stories';
const testCases = Object.values(composeStories(stories))
.filter((Story) => !Story.tags?.includes('skip'))
.map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />);
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react';
import Keypad from './Keypad';
import { useTonePlayer } from '../../hooks/useTonePlayer';
export default {
component: Keypad,
} satisfies Meta<typeof Keypad>;
export const KeypadStory: StoryObj<typeof Keypad> = {
render: () => <Keypad onKeyPress={(key) => console.log(key)} />,
};
export const KeypadStoryWithTone: StoryObj<typeof Keypad> = {
tags: ['skip'],
render: () => {
const playTone = useTonePlayer();
return <Keypad onKeyPress={(key) => playTone(key as any)} />;
},
};

View File

@@ -0,0 +1,43 @@
import { FocusScope } from '@react-aria/focus';
import { Box } from '@rocket.chat/fuselage';
import Key from './Key';
export type KeypadProps = {
onKeyPress(key: string): void;
};
const DIGITS = [
['1', ''],
['2', 'ABC'],
['3', 'DEF'],
['4', 'GHI'],
['5', 'JKL'],
['6', 'MNO'],
['7', 'PQRS'],
['8', 'TUV'],
['9', 'WXYZ'],
['*', ''],
['0', '+', '+'],
['#', ''],
];
const Keypad = ({ onKeyPress }: KeypadProps) => (
<FocusScope autoFocus>
<Box display='flex' justifyContent='center' flexWrap='wrap' maxWidth={196}>
{DIGITS.map(([primaryDigit, alternativeDigit, longPressDigit]) => (
<Key
large={primaryDigit === '*'}
key={primaryDigit}
primaryKey={primaryDigit}
alternativeKey={alternativeDigit}
longPressKey={longPressDigit}
onLongKeyPress={onKeyPress}
onKeyPress={onKeyPress}
/>
))}
</Box>
</FocusScope>
);
export default Keypad;

View File

@@ -0,0 +1,261 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders KeypadStory without crashing 1`] = `
<body>
<div>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
class="rcx-box rcx-box--full rcx-css-wwb6hw"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
1
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
/>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
2
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
ABC
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
3
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
DEF
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
4
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
GHI
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
5
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
JKL
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
6
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
MNO
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
7
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
PQRS
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
8
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
TUV
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
9
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
WXYZ
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-t9aukj"
>
*
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
/>
</span>
</button>
<button
aria-describedby="react-aria-description-0"
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
0
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
>
+
</span>
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-z0me2y rcx-css-1ftod5w"
type="button"
>
<span
class="rcx-button--content"
>
<span
class="rcx-box rcx-box--full rcx-css-1yt2sxa"
>
#
</span>
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-css-6uv1xr"
/>
</span>
</button>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
<div
id="react-aria-description-0"
style="display: none;"
>
Long_press_to_do_x
</div>
</body>
`;

View File

@@ -0,0 +1,72 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import { AutoComplete, Option, Avatar, Field, FieldRow, FieldDescription, FieldError, StatusBullet, Box } from '@rocket.chat/fuselage';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { isFirstPeerAutocompleteOption } from '../context';
export type PeerAutocompleteOptions = {
value: string; // user id
label: string; // name or username
status?: UserStatus;
identifier?: string | number; // extension number
avatarUrl?: string;
};
export type PeerAutocompleteProps = {
options: PeerAutocompleteOptions[];
onChangeValue: (value: string | string[]) => void;
onChangeFilter: (filter: string) => void;
filter: string;
value: string | undefined;
error?: string;
};
const PeerAutocomplete = ({ options, filter, value, onChangeValue, onChangeFilter, error }: PeerAutocompleteProps) => {
const { t } = useTranslation();
const fieldDescriptionId = useId();
const fieldErrorId = useId();
return (
<Field marginBlock={-2}>
<FieldRow>
<AutoComplete
aria-labelledby={fieldDescriptionId}
aria-describedby={error ? fieldErrorId : undefined}
aria-invalid={!!error}
error={!!error}
setFilter={onChangeFilter}
filter={filter}
onChange={onChangeValue}
options={options}
value={value}
renderItem={({ value, label, ...props }) => {
if (isFirstPeerAutocompleteOption(value)) {
return <Option key={value} label={label} icon='phone-out' {...props} />;
}
const thisOption = options.find((option) => option.value === value);
return (
<Option
key={value}
label={
<Box display='flex' flexDirection='row' alignItems='center'>
<StatusBullet status={thisOption?.status} />
<Box marginInlineStart={4}>{label}</Box>
</Box>
}
avatar={<Avatar size='x20' url={thisOption?.avatarUrl || ''} />}
{...props}
/>
);
}}
renderSelected={() => null}
/>
</FieldRow>
{error && <FieldError id={fieldErrorId}>{error}</FieldError>}
<FieldDescription id={fieldDescriptionId}>{t('Enter_username_or_number')}</FieldDescription>
</Field>
);
};
export default PeerAutocomplete;

View File

@@ -0,0 +1,18 @@
import InternalUser from './InternalUser';
import PhoneNumber from './PhoneNumber';
export type ExternalUserProps = {
number: string;
displayName?: string;
avatarUrl?: string;
};
const ExternalUser = ({ number, displayName, avatarUrl }: ExternalUserProps) => {
if (displayName) {
return <InternalUser displayName={displayName} avatarUrl={avatarUrl} callerId={number} />;
}
return <PhoneNumber number={number} />;
};
export default ExternalUser;

View File

@@ -0,0 +1,51 @@
import type { UserStatus } from '@rocket.chat/core-typings';
import { Avatar, Box, Icon, StatusBullet } from '@rocket.chat/fuselage';
import type { Slot } from './useInfoSlots';
export type InternalUserProps = {
displayName: string;
status?: UserStatus;
avatarUrl?: string;
callerId?: string | number;
slots?: Slot[];
remoteMuted?: boolean;
};
const getRemoteStatusText = (slots?: Slot[]) => {
if (!slots?.length) {
return undefined;
}
return slots.map((slot) => slot.text).join(', ');
};
const InternalUser = ({ displayName, avatarUrl, callerId, status, slots, remoteMuted }: InternalUserProps) => {
const remoteStatusText = getRemoteStatusText(slots);
return (
<Box display='flex' flexDirection='row' id='rcx-media-call-widget-caller-info'>
<Box marginInlineEnd={8}>{avatarUrl ? <Avatar url={avatarUrl} size='x20' /> : <Icon name='user' size='x20' />}</Box>
<Box display='flex' flexDirection='column' flexGrow={1}>
<Box display='flex' flexDirection='row' alignItems='center' fontScale='p2b' color='default' justifyContent='space-between'>
<Box is='span' display='flex' flexDirection='row' alignItems='center'>
{status && <StatusBullet status={status} size='small' />}
<Box marginInlineStart={4}>{displayName}</Box>
</Box>
{remoteMuted && <Icon name='mic-off' size='x16' color='danger' />}
</Box>
{callerId && (
<Box fontScale='c1' color='secondary-info'>
{callerId}
{remoteStatusText ? ` - ${remoteStatusText}` : undefined}
</Box>
)}
{!callerId && remoteStatusText && (
<Box fontScale='c1' color='secondary-info'>
{remoteStatusText}
</Box>
)}
</Box>
</Box>
);
};
export default InternalUser;

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './PeerInfo.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />);
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,89 @@
import { UserStatus } from '@rocket.chat/core-typings';
import type { Meta, StoryObj } from '@storybook/react';
import { PeerInfo } from '.';
export default {
component: PeerInfo,
decorators: [(Story) => <Story />],
} satisfies Meta<typeof PeerInfo>;
export const InternalUser: StoryObj<typeof PeerInfo> = {
render: () => (
<PeerInfo
callerId='1234'
displayName='John Doe'
status={UserStatus.ONLINE}
avatarUrl='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'
/>
),
};
export const InternalUserWithRemoteStatus: StoryObj<typeof PeerInfo> = {
render: () => (
<PeerInfo
callerId='1234'
displayName='John Doe'
status={UserStatus.ONLINE}
slots={[
{
text: 'On hold',
type: 'info',
},
{
text: 'Muted',
type: 'warning',
},
]}
avatarUrl='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'
/>
),
};
export const ExternalUser: StoryObj<typeof PeerInfo> = {
render: () => <PeerInfo number='1234567890' />,
};
export const ExternalUserWithDisplayName: StoryObj<typeof PeerInfo> = {
render: () => <PeerInfo number='1234567890' displayName='Jane Doe' />,
};
export const ExternalUserWithDisplayNameAndAvatar: StoryObj<typeof PeerInfo> = {
render: () => (
<PeerInfo
number='1234567890'
displayName='Jane Doe'
avatarUrl='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'
/>
),
};

View File

@@ -0,0 +1,15 @@
import { ExternalUser, InternalUser } from '.';
import type { ExternalUserProps } from './ExternalUser';
import type { InternalUserProps } from './InternalUser';
export type PeerInfoProps = InternalUserProps | ExternalUserProps;
const PeerInfo = (props: PeerInfoProps) => {
if ('number' in props) {
return <ExternalUser {...props} />;
}
return <InternalUser {...props} />;
};
export default PeerInfo;

View File

@@ -0,0 +1,16 @@
import { Box, Icon } from '@rocket.chat/fuselage';
export type PhoneNumberProps = {
number: string;
};
const PhoneNumber = ({ number }: PhoneNumberProps) => {
return (
<Box display='flex' flexDirection='row' id='rcx-media-call-widget-caller-info'>
<Icon size='x20' name='phone' marginInlineEnd={8} aria-hidden='true' />
<Box fontScale='p2b'>{number}</Box>
</Box>
);
};
export default PhoneNumber;

View File

@@ -0,0 +1,264 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders ExternalUser without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1i4juyg"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-1rfo3kt"
>
1234567890
</div>
</div>
</div>
</body>
`;
exports[`renders ExternalUserWithDisplayName without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-user rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-q52znk"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
Jane Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1c2tjdu"
>
1234567890
</div>
</div>
</div>
</div>
</body>
`;
exports[`renders ExternalUserWithDisplayNameAndAvatar without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20"
>
<img
alt=""
class="rcx-avatar__element rcx-avatar__element--x20"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-q52znk"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
Jane Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1c2tjdu"
>
1234567890
</div>
</div>
</div>
</div>
</body>
`;
exports[`renders InternalUser without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20"
>
<img
alt=""
class="rcx-avatar__element rcx-avatar__element--x20"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-q52znk"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<svg
aria-hidden="true"
class="rcx-status-bullet rcx-status-bullet--online undefined rcx-status-bullet--small"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24 12.0001C24 18.6275 18.6274 24.0001 12 24.0001C5.37255 24.0001 -3.05176e-05 18.6275 -3.05176e-05 12.0001C-3.05176e-05 5.37271 5.37255 0.00012207 12 0.00012207C18.6274 0.00012207 24 5.37271 24 12.0001Z"
/>
</svg>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
John Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1c2tjdu"
>
1234
</div>
</div>
</div>
</div>
</body>
`;
exports[`renders InternalUserWithRemoteStatus without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20"
>
<img
alt=""
class="rcx-avatar__element rcx-avatar__element--x20"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-q52znk"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<svg
aria-hidden="true"
class="rcx-status-bullet rcx-status-bullet--online undefined rcx-status-bullet--small"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24 12.0001C24 18.6275 18.6274 24.0001 12 24.0001C5.37255 24.0001 -3.05176e-05 18.6275 -3.05176e-05 12.0001C-3.05176e-05 5.37271 5.37255 0.00012207 12 0.00012207C18.6274 0.00012207 24 5.37271 24 12.0001Z"
/>
</svg>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
John Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1c2tjdu"
>
1234
- On hold, Muted
</div>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,5 @@
export { default as InternalUser } from './InternalUser';
export { default as ExternalUser } from './ExternalUser';
export { default as PhoneNumber } from './PhoneNumber';
export { default as PeerInfo } from './PeerInfo';
export { type PeerInfoProps } from './PeerInfo';

View File

@@ -0,0 +1,69 @@
import type { Keys as IconNames } from '@rocket.chat/icons';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConnectionState } from '../../context';
export type Slot = {
text: string;
type: 'warning' | 'info';
icon?: IconNames;
};
const getMutedSlot = (muted: boolean, t: ReturnType<typeof useTranslation>['t']) => {
if (muted) {
return {
text: t('Muted'),
type: 'warning',
} as const;
}
return undefined;
};
const getHeldSlot = (held: boolean, t: ReturnType<typeof useTranslation>['t']) => {
if (held) {
return {
text: t('On_Hold'),
type: 'info',
} as const;
}
return undefined;
};
const getConnectionStateSlot = (connectionState: ConnectionState, t: ReturnType<typeof useTranslation>['t']) => {
if (connectionState === 'RECONNECTING') {
return {
text: t('meteor_status_connecting'),
type: 'warning',
icon: 'warning',
} as const;
}
return undefined;
};
export const useInfoSlots = (muted: boolean, held: boolean, connectionState?: ConnectionState): Slot[] => {
const { t } = useTranslation();
const [slots, setSlots] = useState<Slot[]>([]);
useEffect(() => {
const slots: Slot[] = [];
const heldSlot = getHeldSlot(held, t);
const mutedSlot = getMutedSlot(muted, t);
const connectionStateSlot = connectionState ? getConnectionStateSlot(connectionState, t) : undefined;
if (connectionStateSlot) {
slots.push(connectionStateSlot);
setSlots(slots);
return;
}
if (heldSlot) {
slots.push(heldSlot);
}
if (mutedSlot) {
slots.push(mutedSlot);
}
setSlots(slots);
}, [muted, held, t, connectionState]);
return slots;
};

View File

@@ -0,0 +1,51 @@
import { Box } from '@rocket.chat/fuselage';
import { useEffect, useState } from 'react';
export type VoipTimerProps = { startAt?: Date };
const VoipTimer = ({ startAt }: VoipTimerProps) => {
const [start] = useState(() => {
if (!startAt) {
return Date.now();
}
return startAt.getTime();
});
const [ellapsedTime, setEllapsedTime] = useState(() => {
if (!start) {
return 0;
}
return Date.now() - start;
});
useEffect(() => {
const interval = setInterval(() => {
setEllapsedTime(() => {
const now = Date.now();
return now - start;
});
}, 1000);
return () => clearInterval(interval);
}, [start]);
const totalSeconds = Math.floor(ellapsedTime / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const hoursStr = hours.toString().padStart(2, '0');
const minutesStr = minutes.toString().padStart(2, '0');
const secondsStr = seconds.toString().padStart(2, '0');
return (
<Box is='time' dateTime={`PT${hours}H${minutes}M${seconds}S`} fontScale='p1b'>
{hoursStr !== '00' ? `${hours}:` : ''}
{minutesStr}:{secondsStr}
</Box>
);
};
export default VoipTimer;

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './ToggleButton.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />);
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import ToggleButton from './ToggleButton';
export default {
component: ToggleButton,
} satisfies Meta<typeof ToggleButton>;
export const ToggleButtonStory: StoryObj<typeof ToggleButton> = {
render: () => {
const [pressed, setPressed] = useState(false);
return (
<ToggleButton
label='Mute'
titles={['Mute', 'Unmute']}
icons={['mic', 'mic-off']}
pressed={pressed}
onToggle={() => setPressed(!pressed)}
/>
);
},
};

View File

@@ -0,0 +1,60 @@
import { Icon, IconButton } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { ComponentProps } from 'react';
export type ToggleButtonProps = {
label: string; // label should not change due to a11y constraints
icons: [defaultIcon: Keys, pressedIcon: Keys];
titles: [defaultTitle: string, pressedTitle: string]; // Titles might change though
disabled?: boolean;
pressed?: boolean;
/**
* Whether *pressed* is the state worth shouting about — a muted mic, a dark camera — in which case the whole
* button goes `secondary-danger` rather than only its glyph turning red.
*
* Opt-in, because for most of these toggles pressed means *active*: a shared screen or a raised hand is not a
* problem, and colouring it as one would read as an error.
*/
dangerWhenPressed?: boolean;
/** Renders the larger variant used by the conference UI. The widget keeps its original size by default. */
large?: boolean;
onToggle?: () => void;
} & Omit<ComponentProps<typeof IconButton>, 'icon' | 'title' | 'aria-label' | 'disabled' | 'onClick'>;
const ToggleButton = ({
disabled,
label,
pressed,
icons,
titles,
onToggle,
danger = true,
secondary = true,
tiny = false,
large = false,
dangerWhenPressed = false,
...props
}: ToggleButtonProps) => (
<IconButton
{...props}
label={label}
tiny={tiny}
large={large}
medium={!tiny && !large}
secondary={secondary}
danger={dangerWhenPressed && pressed}
icon={
<Icon
size={large ? 20 : 16}
color={!dangerWhenPressed && pressed && danger ? 'font-danger' : undefined}
name={icons[pressed ? 1 : 0]}
/>
}
title={titles[pressed ? 1 : 0]}
aria-label={label}
disabled={disabled}
onClick={onToggle}
/>
);
export default ToggleButton;

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './Widget.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />);
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,37 @@
import { ButtonGroup, Button, IconButton } from '@rocket.chat/fuselage';
import type { Meta, StoryObj } from '@storybook/react';
import { Widget, WidgetHeader, WidgetFooter, WidgetHandle, WidgetInfo, WidgetContent } from '.';
export default {
component: Widget,
decorators: [(Story) => <Story />],
} satisfies Meta<typeof Widget>;
export const FullWidget: StoryObj<typeof Widget> = {
render: () => (
<Widget>
<WidgetHandle />
<WidgetHeader title='Incoming Call...'>
<IconButton name='close' icon='cross' aria-label='Close' mini />
</WidgetHeader>
<WidgetInfo
slots={[
{ text: 'On hold', type: 'info' },
{ text: 'Muted', type: 'warning' },
]}
/>
<WidgetContent>The content of the widget</WidgetContent>
<WidgetFooter>
<ButtonGroup stretch>
<Button medium name='phone-off' icon='phone-off' danger>
Reject
</Button>
<Button medium name='phone' icon='phone' success>
Accept
</Button>
</ButtonGroup>
</WidgetFooter>
</Widget>
),
};

View File

@@ -0,0 +1,30 @@
import { FocusScope } from '@react-aria/focus';
import type { ReactNode } from 'react';
import WidgetBase from './WidgetBase';
import { useDraggableWidget } from './WidgetDraggableContext';
export type WidgetProps = {
children: ReactNode;
autoFocus?: boolean;
};
const Widget = ({ children, autoFocus = true, ...props }: WidgetProps) => {
const draggableContext = useDraggableWidget();
return (
<FocusScope autoFocus={autoFocus}>
<WidgetBase
{...props}
ref={draggableContext?.draggableRef}
inline={!draggableContext}
role='dialog'
aria-labelledby='rcx-media-call-widget-title-prefix rcx-media-call-widget-title rcx-media-call-widget-caller-info'
>
{children}
</WidgetBase>
</FocusScope>
);
};
export default Widget;

View File

@@ -0,0 +1,38 @@
import { Palette } from '@rocket.chat/fuselage';
import styled from '@rocket.chat/styled';
type WidgetBaseProps = { inline?: boolean; borderless?: boolean };
const WidgetBase = styled('div', ({ borderless: _borderless, inline: _inline, ...props }: WidgetBaseProps) => props)`
padding: 0;
display: flex;
flex-direction: column;
width: 248px;
min-height: 128px;
border-radius: 4px;
background-color: ${Palette.surface['surface-tint'].toString()};
color: ${Palette.text['font-default'].toString()};
overflow: hidden;
${({ borderless }) =>
borderless
? ''
: `
border: 1px solid ${Palette.stroke['stroke-dark'].toString()};
`}
${({ inline }) =>
inline
? ''
: `
position: fixed;
right: 2em;
top: 11em;
z-index: 100;
box-shadow:
0px 0px 1px 0px ${Palette.shadow['shadow-elevation-2x'].toString()},
0px 0px 12px 0px ${Palette.shadow['shadow-elevation-2y'].toString()};
`}
`;
export default WidgetBase;

View File

@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type WidgetContentProps = { children: ReactNode };
const WidgetContent = ({ children }: WidgetContentProps) => (
<Box is='section' marginBlock={4} marginInline={12} flexGrow={1}>
{children}
</Box>
);
export default WidgetContent;

View File

@@ -0,0 +1,12 @@
import type { RefCallback } from 'react';
import { createContext, useContext } from 'react';
type DragContextValue = {
draggableRef: RefCallback<HTMLElement>;
boundingRef: RefCallback<HTMLElement>;
handleRef: RefCallback<HTMLElement>;
};
export const DragContext = createContext<DragContextValue | undefined>(undefined);
export const useDraggableWidget = (): DragContextValue | undefined => useContext(DragContext);

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { useLayoutEffect } from 'react';
import { DragContext } from './WidgetDraggableContext';
import { useMediaCallView } from '../../context';
import { useDraggable } from '../../hooks';
type WidgetDraggableProviderProps = {
children: ReactNode;
};
const WidgetDraggableProvider = ({ children }: WidgetDraggableProviderProps) => {
const { widgetPositionTracker } = useMediaCallView();
const [draggableRef, boundingRef, handleRef] = useDraggable({
onChangePosition: widgetPositionTracker?.onChangePosition,
restorePosition: widgetPositionTracker?.lastKnownPosition,
});
useLayoutEffect(() => {
boundingRef(document.body);
}, [boundingRef]);
return <DragContext.Provider value={{ draggableRef, boundingRef, handleRef }}>{children}</DragContext.Provider>;
};
export default WidgetDraggableProvider;

View File

@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type WidgetFooterProps = { children: ReactNode };
const WidgetFooter = ({ children }: WidgetFooterProps) => (
<Box is='footer' padding={12} backgroundColor='surface-light' marginBlockStart={4}>
{children}
</Box>
);
export default WidgetFooter;

View File

@@ -0,0 +1,35 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Icon, Palette } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useDraggableWidget } from './WidgetDraggableContext';
const dragHandle = css`
cursor: grab;
background-color: ${Palette.surface['surface-tint'].toString()};
color: ${Palette.text['font-default'].toString()};
&:hover {
background-color: ${Palette.surface['surface-neutral'].toString()};
color: ${Palette.text['font-info'].toString()};
}
&:active {
cursor: grabbing;
}
`;
const WidgetHandle = (props: ComponentProps<typeof Box>) => {
const draggableContext = useDraggableWidget();
if (!draggableContext) {
return null;
}
const { handleRef } = draggableContext;
return (
<Box height={20} display='flex' flexDirection='row' justifyContent='center' className={dragHandle} ref={handleRef} {...props}>
<Icon name='stacked-meatballs' size='x20' />
</Box>
);
};
export default WidgetHandle;

View File

@@ -0,0 +1,26 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { VisuallyHidden } from 'react-aria';
import { useTranslation } from 'react-i18next';
export type WidgetHeaderProps = {
title: ReactNode;
children: ReactNode;
};
// TODO: A11Y - duration/title
const WidgetHeader = ({ title, children }: WidgetHeaderProps) => {
const { t } = useTranslation();
return (
<Box is='header' marginInline={12} marginBlock={4} display='flex' alignItems='center' justifyContent='space-between'>
<VisuallyHidden id='rcx-media-call-widget-title-prefix'>{t('Voice_call')}</VisuallyHidden>
<Box is='h3' color='titles-labels' fontScale='p1b' id='rcx-media-call-widget-title'>
{title}
</Box>
<Box marginInlineStart={8} display='flex' flexDirection='row'>
{children}
</Box>
</Box>
);
};
export default WidgetHeader;

View File

@@ -0,0 +1,43 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import type { Keys as IconNames } from '@rocket.chat/icons';
type Slot = {
text: string;
type: 'warning' | 'info';
icon?: IconNames;
};
export type WidgetInfoProps = {
slots?: Slot[];
variant?: 'default' | 'card-content';
};
const WidgetInfo = ({ slots, variant = 'default' }: WidgetInfoProps) => {
if (!slots?.length) {
return null;
}
return (
<Box
is='span'
display='flex'
flexDirection='row'
justifyContent={variant === 'card-content' ? 'center' : 'space-between'}
marginInline={12}
marginBlockStart={4}
marginBlockEnd={variant === 'card-content' ? 0 : 4}
>
{slots.map((slot) => (
<Box
is='span'
color={slot.type === 'warning' ? 'status-font-on-warning' : undefined}
fontScale={variant === 'card-content' ? 'c1' : 'p2'}
key={slot.text}
>
{slot.icon && <Icon name={slot.icon} marginBlockEnd={4} />} {slot.text}
</Box>
))}
</Box>
);
};
export default WidgetInfo;

View File

@@ -0,0 +1,119 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders FullWidget without crashing 1`] = `
<body>
<div>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
aria-labelledby="rcx-media-call-widget-title-prefix rcx-media-call-widget-title rcx-media-call-widget-caller-info"
class="rcx-css-1gxjmy4"
role="dialog"
>
<header
class="rcx-box rcx-box--full rcx-css-4vvilp"
>
<div
id="rcx-media-call-widget-title-prefix"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
Voice_call
</div>
<h3
class="rcx-box rcx-box--full rcx-css-uge1yt"
id="rcx-media-call-widget-title"
>
Incoming Call...
</h3>
<div
class="rcx-box rcx-box--full rcx-css-qwfmt5"
>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--mini-square rcx-button--square rcx-button--icon rcx-button"
name="close"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-1tg7yd0"
>
</i>
</button>
</div>
</header>
<span
class="rcx-box rcx-box--full rcx-css-1k2ffyh"
>
<span
class="rcx-box rcx-box--full rcx-css-1gd28qh"
>
On hold
</span>
<span
class="rcx-box rcx-box--full rcx-css-z06lf7"
>
Muted
</span>
</span>
<section
class="rcx-box rcx-box--full rcx-css-8j42zp"
>
The content of the widget
</section>
<footer
class="rcx-box rcx-box--full rcx-css-1koqfyi"
>
<div
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--medium rcx-button--danger rcx-button"
name="phone-off"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone-off rcx-icon rcx-css-1hdf9ok"
>
</i>
Reject
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--medium rcx-button--success rcx-button"
name="phone"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-phone rcx-icon rcx-css-1hdf9ok"
>
</i>
Accept
</span>
</button>
</div>
</footer>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</body>
`;

View File

@@ -0,0 +1,6 @@
export { default as Widget } from './Widget';
export { default as WidgetHeader } from './WidgetHeader';
export { default as WidgetFooter } from './WidgetFooter';
export { default as WidgetHandle } from './WidgetHandle';
export { default as WidgetInfo } from './WidgetInfo';
export { default as WidgetContent } from './WidgetContent';

View File

@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders ToggleButtonStory without crashing 1`] = `
<body>
<div>
<button
aria-label="Mute"
class="rcx-box rcx-box--full rcx-button--medium-square rcx-button--icon-secondary rcx-button--square rcx-button--icon rcx-button"
label="Mute"
title="Mute"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-mic rcx-icon rcx-css-xb06ut"
>
</i>
</button>
</div>
</body>
`;

View File

@@ -0,0 +1,15 @@
export * from './Widget';
export * from './PeerInfo';
export * from './PeerAutocomplete';
export * from './Cards';
export * from './Actions';
export { default as ToggleButton } from './ToggleButton';
export { default as ActionButton } from './ActionButton';
export { default as Keypad } from './Keypad/Keypad';
export { useInfoSlots } from './PeerInfo/useInfoSlots';
export { default as PeerAutocomplete } from './PeerAutocomplete';
export { default as Timer } from './Timer';
export { default as DevicePicker } from './DevicePicker';
export { default as CallHistoryInternalUser } from './CallHistoryInternalUser';
export { default as CallHistoryExternalUser } from './CallHistoryExternalUser';

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 };
};

View File

@@ -0,0 +1,5 @@
export type IceServer = {
urls: string;
username?: string;
credential?: string;
};

View File

@@ -0,0 +1,30 @@
export type CallHistoryExternalContact = {
number: string;
};
export type CallHistoryInternalContact = {
_id: string;
name?: string;
username: string;
displayName?: string;
voiceCallExtension?: string;
avatarUrl?: string;
};
export type CallHistoryUnknownContact = {
unknown: true;
};
export type CallHistoryContact = CallHistoryInternalContact | CallHistoryExternalContact | CallHistoryUnknownContact;
export const isCallHistoryUnknownContact = (contact: CallHistoryContact): contact is CallHistoryUnknownContact => {
return 'unknown' in contact && contact.unknown;
};
export const isCallHistoryInternalContact = (contact: CallHistoryContact): contact is CallHistoryInternalContact => {
return '_id' in contact && Boolean(contact._id);
};
export const isCallHistoryExternalContact = (contact: CallHistoryContact): contact is CallHistoryExternalContact => {
return !isCallHistoryUnknownContact(contact) && !isCallHistoryInternalContact(contact);
};

View File

@@ -0,0 +1,2 @@
export * from './callHistoryContacts';
export type * from './IceServer';

View File

@@ -0,0 +1,136 @@
import { readFileSync, writeFileSync } from 'fs';
import { createRequire } from 'module';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { CSSProperties } from 'react';
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
// 1. Set up a jsdom DOM environment before loading fuselage.
// Fuselage's webpack bundle reads window/document/document.body at module init time.
// renderToStaticMarkup never calls effects, so the DOM is never actually used.
const { JSDOM } = require('jsdom') as { JSDOM: new (html: string, options?: { url?: string }) => { window: Window & typeof globalThis } };
const { window: jsdomWindow } = new JSDOM('<!DOCTYPE html><body></body>', { url: 'http://localhost' });
const g = globalThis as Record<string, unknown>;
g.self = jsdomWindow;
g.window = jsdomWindow;
g.document = jsdomWindow.document;
// 2. Redirect all React-family CJS requires to this repo's copy.
// When @rocket.chat/fuselage is symlinked from a separate dev repo it has its own
// node_modules/react, which creates a second React instance and breaks hook calls.
// Module._resolveFilename runs before the module cache lookup, so this forces every
// require('react') — including fuselage's internal ones — to resolve to the same file.
const NodeModule = require('module') as { _resolveFilename: (...args: unknown[]) => string };
const origResolveFilename = NodeModule._resolveFilename.bind(NodeModule);
const reactRedirects: Record<string, string> = {
'react': require.resolve('react'),
'react/jsx-runtime': require.resolve('react/jsx-runtime'),
'react-dom': require.resolve('react-dom'),
'react-dom/server': require.resolve('react-dom/server'),
};
NodeModule._resolveFilename = (...args: unknown[]) => {
const request = args[0] as string;
return reactRedirects[request] ?? origResolveFilename(...args);
};
// 3. Load fuselage and react-dom/server after all redirects are in place.
const { renderToStaticMarkup } = require('react-dom/server') as typeof import('react-dom/server');
const { flushSync } = require('react-dom') as typeof import('react-dom');
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client');
const { States, StatesIcon, StatesSubtitle, StatesTitle, PaletteStyleTag } =
require('@rocket.chat/fuselage') as typeof import('@rocket.chat/fuselage');
const fuselageCssPath = require.resolve('@rocket.chat/fuselage/dist/fuselage.css');
// 4. Embed the RocketChat icon font as a base64 data URI.
// fuselage.css sets font-family:RocketChat on .rcx-icon but does not include the
// @font-face declaration — that lives in @rocket.chat/icons. Inlining the woff2
// makes the HTML fully self-contained with no external font file dependencies.
const iconFontPath = require.resolve('@rocket.chat/icons/dist/font/rocketchat.woff2');
const iconFontBase64 = readFileSync(iconFontPath).toString('base64');
const iconFontCss = `@font-face {
font-family: 'RocketChat';
font-weight: 400;
font-style: normal;
font-display: auto;
src: url('data:font/woff2;base64,${iconFontBase64}') format('woff2');
}`;
// 5. Render PaletteStyleTag for all three themes using the real DOM renderer so its
// createPortal calls work. Each instance injects a <style> tag into jsdom's
// document.head, which we collect into dynamicStyles below.
// renderToStaticMarkup does not support portals, so we use flushSync + createRoot.
const paletteContainer = jsdomWindow.document.createElement('div');
jsdomWindow.document.body.appendChild(paletteContainer);
const paletteRoot = createRoot(paletteContainer);
flushSync(() => {
paletteRoot.render(
<>
<PaletteStyleTag theme='light' tagId='palette-light' />
<PaletteStyleTag theme='dark' tagId='palette-dark' selector='[data-color-scheme="dark"]' />
<PaletteStyleTag theme='high-contrast' tagId='palette-high-contrast' selector='[data-color-scheme="high-contrast"]' />
</>,
);
});
// Use a plain <div> for the outer container: Box's dynamic CSS classes (display, bg, etc.)
// are inserted via useInsertionEffect which doesn't run in renderToStaticMarkup.
// Inline styles and a CSS variable reference cover the same behavior.
const containerStyle: CSSProperties = {
display: 'flex',
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
left: 0,
top: 0,
bottom: 0,
right: 0,
backgroundColor: 'var(--rcx-color-surface-tint)',
zIndex: -1,
};
function LandingView() {
return (
<div style={containerStyle}>
<States>
<StatesIcon name='phone' />
<StatesTitle data-i18n='Call_window'>Call window</StatesTitle>
<StatesSubtitle data-i18n='You_can_close_this_window'>You can close this window.</StatesSubtitle>
</States>
</div>
);
}
const body = renderToStaticMarkup(<LandingView />);
// In browser mode (jsdom present), @rocket.chat/css-in-js inserts generated styles
// into document.head via attachRules. Collect them now so we can inline them.
const dynamicStyles = Array.from(jsdomWindow.document.head.querySelectorAll('style'))
.map((el) => el.textContent ?? '')
.join('\n');
const fuselageCss = readFileSync(fuselageCssPath, 'utf-8');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>html, body { margin: 0; padding: 0; width: 100%; height: 100%; }</style>
<style>${iconFontCss}</style>
<style>${fuselageCss}</style>
<style>${dynamicStyles}</style>
</head>
<body>
<div id="root" style="position: relative; width: 100%; height: 100vh;" class="rcx-box--full">${body}</div>
</body>
</html>`;
writeFileSync(`${__dirname}/voice-call-popup.html`, html, 'utf-8');
console.log('Generated dist/voice-call-popup.html');

7
packages/ui-voip/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';
declare global {
interface Window {
RocketChatDesktop?: IRocketChatDesktop;
}
}

View File

@@ -0,0 +1,586 @@
import { Emitter } from '@rocket.chat/emitter';
import type { OffCallbackHandler } from '@rocket.chat/emitter';
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { useCallback, useRef, useState } from 'react';
const GRAB_DOM_EVENTS = ['pointerdown'] as const;
const RELEASE_DOM_EVENTS = ['pointerup', 'pointercancel', 'lostpointercapture'] as const;
const MOVE_DOM_EVENTS = ['pointermove'] as const;
interface IPointCoordinates {
x: number;
y: number;
}
interface IGenericRect extends IPointCoordinates {
width: number;
height: number;
}
class GenericRect {
private rect: IGenericRect;
constructor(rect: IGenericRect) {
this.rect = rect;
}
get x(): number {
return this.rect.x;
}
get y(): number {
return this.rect.y;
}
get width(): number {
return this.rect.width;
}
get height(): number {
return this.rect.height;
}
get top(): number {
return this.rect.y;
}
get left(): number {
return this.rect.x;
}
get right(): number {
return this.rect.x + this.rect.width;
}
get bottom(): number {
return this.rect.y + this.rect.height;
}
}
type DraggableElementEvents = {
grab: IGenericRect;
move: IPointCoordinates;
release: IGenericRect;
changeView: IDraggableElement;
resize: IGenericRect;
};
class Draggable extends Emitter<DraggableElementEvents> {
private _element: IDraggableElement;
private isDragging = false;
private pointerCoordinates: IPointCoordinates = { x: 0, y: 0 };
private storedPositionOffset: IPointCoordinates = { x: 0, y: 0 };
constructor(element: IDraggableElement) {
super();
this._element = element;
this._element.onMove((pointerCoordinates) => {
this.handleMove(pointerCoordinates);
});
this._element.onRelease((rect) => {
this.handleRelease(rect);
});
this._element.onChangeView((draggableElement) => {
this.emit('changeView', draggableElement);
});
this._element.onResize((rect) => {
this.emit('resize', rect);
});
}
get element(): IDraggableElement {
return this._element;
}
public onRelease(cb: (rect: IGenericRect) => void): OffCallbackHandler {
return this.on('release', cb);
}
public onMove(cb: (pointerPosition: IPointCoordinates) => void): OffCallbackHandler {
return this.on('move', cb);
}
public onResize(cb: (rect: IGenericRect) => void): OffCallbackHandler {
return this.on('resize', cb);
}
public onChangeView(cb: (element: IDraggableElement) => void): OffCallbackHandler {
return this.on('changeView', cb);
}
private setPointerCoordinates(pointerCoordinates: IPointCoordinates): void {
this.pointerCoordinates = pointerCoordinates;
}
private addElementPositionOffset(x: number, y: number): void {
const currentOffset = this.getStoredOffset();
this.setStoredPositionOffset(currentOffset.x + x, currentOffset.y + y);
}
private setStoredPositionOffset(x: number, y: number): void {
this.storedPositionOffset = { x, y };
this.element.setElementPositionOffset(this.storedPositionOffset);
}
public moveToCoordinates(targetElementCoordinates: IPointCoordinates, initialPosition: IGenericRect): void {
this.setStoredPositionOffset(targetElementCoordinates.x - initialPosition.x, targetElementCoordinates.y - initialPosition.y);
this.emit('move', this.getStoredOffset());
}
public handleGrab(startingPointerCoordinates: IPointCoordinates, elementRect: IGenericRect): void {
this.isDragging = true;
this.setPointerCoordinates(startingPointerCoordinates);
this.emit('grab', elementRect);
}
public handleMove(currentPointerCoordinates: IPointCoordinates): void {
if (!this.isDragging) return;
const xDelta = currentPointerCoordinates.x - this.pointerCoordinates.x;
const yDelta = currentPointerCoordinates.y - this.pointerCoordinates.y;
this.addElementPositionOffset(xDelta, yDelta);
this.setPointerCoordinates(currentPointerCoordinates);
const storedOffset = this.getStoredOffset();
this.element.setElementPositionOffset(storedOffset);
this.emit('move', storedOffset);
}
public handleRelease(finalElementPosition: IGenericRect): void {
if (!this.isDragging) {
return;
}
this.isDragging = false;
this.setPointerCoordinates({ x: 0, y: 0 });
this.emit('release', finalElementPosition);
}
public moveByOffset(offset: IPointCoordinates): void {
this.addElementPositionOffset(offset.x, offset.y);
this.emit('move', this.getStoredOffset());
}
public getStoredOffset(): IPointCoordinates {
return this.storedPositionOffset;
}
}
type BoundingElementOptions = {
resizeDebounce: number; // debounce time in ms
};
export const DEFAULT_BOUNDING_ELEMENT_OPTIONS: BoundingElementOptions = {
resizeDebounce: 150,
};
class BoundingElement extends Emitter<{
resize: IGenericRect;
}> {
private _element: IBoundingElement;
private draggableInstance: Draggable;
private resizeDebounce: number;
private timeout: NodeJS.Timeout | null = null;
constructor(element: IBoundingElement, draggableInstance: Draggable, options = DEFAULT_BOUNDING_ELEMENT_OPTIONS) {
super();
this._element = element;
this.draggableInstance = draggableInstance;
this.resizeDebounce = options.resizeDebounce;
this.draggableInstance.onRelease(() => {
this.tryMoveToBounds();
});
this.element.onResize(() => {
this.tryMoveToBounds();
});
this.draggableInstance.onResize(() => {
this.tryMoveToBounds();
});
this.draggableInstance.onChangeView(() => {
this.tryMoveToBounds();
});
this.element.onChangeView(() => {
this.tryMoveToBounds();
});
}
get element(): IBoundingElement {
return this._element;
}
private _tryMoveToBounds(): void {
const draggableRect = this.draggableInstance.element.getElementRect();
const boundsRect = this.element.getElementRect();
if (!draggableRect || !boundsRect) {
return;
}
const offset = this.calculateBoundsOffset(draggableRect, boundsRect);
this.draggableInstance.moveByOffset(offset);
}
private tryMoveToBounds(): void {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this._tryMoveToBounds();
}, this.resizeDebounce);
}
private calculateBoundsOffset(_draggableRect: IGenericRect, _boundsRect: IGenericRect): IPointCoordinates {
const draggableRect = new GenericRect(_draggableRect);
const boundsRect = new GenericRect(_boundsRect);
// If the draggable element's top/left position is less than
// the bounding element's top/left position, the difference will be positive
// This means we can just add this value to the draggable element's offset
// to bring it back into bounds
const topLeftPoint = {
x: boundsRect.left - draggableRect.left,
y: boundsRect.top - draggableRect.top,
};
// If the draggable element's bottom/right position is greater than
// the bounding element's bottom/right position, the difference will be negative
// This means we can just add this value to the draggable element's offset
// to bring it back into bounds
const bottomRightPoint = {
x: boundsRect.right - draggableRect.right,
y: boundsRect.bottom - draggableRect.bottom,
};
const offset = {
x: 0,
y: 0,
};
if (topLeftPoint.x > 0) {
offset.x += topLeftPoint.x;
}
if (topLeftPoint.y > 0) {
offset.y += topLeftPoint.y;
}
if (bottomRightPoint.x < 0) {
offset.x += bottomRightPoint.x;
}
if (bottomRightPoint.y < 0) {
offset.y += bottomRightPoint.y;
}
return offset;
}
}
class HandleElement extends Emitter<{
grab: IPointCoordinates;
}> {
private draggableInstance: Draggable;
private _element: IHandleElement;
constructor(element: IHandleElement, draggableInstance: Draggable) {
super();
this.draggableInstance = draggableInstance;
this._element = element;
this._element.onGrab(([mousePosition, elementRect]) => {
this.draggableInstance.handleGrab(mousePosition, elementRect);
});
}
get element(): IHandleElement {
return this._element;
}
}
const getPointerEventCoordinates = (e: PointerEvent): IPointCoordinates => ({
x: e.clientX,
y: e.clientY,
});
type GetElementRect = () => IGenericRect | null;
type OnChangeView<TElement> = (cb: (element: TElement) => void) => OffCallbackHandler;
interface IDraggableElement {
setElement(element: unknown): OffCallbackHandler;
setElementPositionOffset(offset: IPointCoordinates): void;
getElementRect: GetElementRect;
// events
onMove(cb: (pointerPosition: IPointCoordinates) => void): OffCallbackHandler;
onChangeView: OnChangeView<IDraggableElement>;
onRelease(cb: (rect: IGenericRect) => void): OffCallbackHandler;
onResize(cb: (rect: IGenericRect) => void): OffCallbackHandler;
}
interface IBoundingElement {
setElement(element: unknown): OffCallbackHandler;
getElementRect: GetElementRect;
// events
onResize(cb: (rect: IGenericRect) => void): OffCallbackHandler;
onChangeView: OnChangeView<IBoundingElement>;
}
interface IHandleElement {
setElement(element: unknown): OffCallbackHandler;
// events
onGrab(cb: (event: [mousePosition: IPointCoordinates, elementRect: IGenericRect]) => void): OffCallbackHandler;
}
const isLeftClick = (event: PointerEvent) => event.button === 0;
const isMousePointer = (event: PointerEvent) => event.pointerType === 'mouse';
class HandleDomElement
extends Emitter<{
grab: [IPointCoordinates, IGenericRect];
}>
implements IHandleElement
{
public setElement(element: HTMLElement) {
// Prevent text selection in the handle and behind the widget
element.style.userSelect = 'none';
element.style.webkitUserSelect = 'none';
const onGrab = (event: PointerEvent) => {
const element = event.currentTarget as HTMLElement;
if (!element || (isMousePointer(event) && !isLeftClick(event))) {
return;
}
this.emit('grab', [getPointerEventCoordinates(event), element.getBoundingClientRect()]);
};
const unsubArray: OffCallbackHandler[] = [];
unsubArray.push(
...GRAB_DOM_EVENTS.map((event) => {
element.addEventListener(event, onGrab);
return () => element.removeEventListener(event, onGrab);
}),
);
return () => unsubArray.forEach((unsub) => unsub());
}
public onGrab = (cb: (event: [mousePosition: IPointCoordinates, elementRect: IGenericRect]) => void): OffCallbackHandler => {
return this.on('grab', cb);
};
}
class BoundingDomElement
extends Emitter<{
resize: IGenericRect;
changeView: IBoundingElement;
}>
implements IBoundingElement
{
private _element: HTMLElement | null = null;
public setElement(element: HTMLElement) {
this._element = element;
const onResize = (entries: ResizeObserverEntry[]) => {
const firstEntry = entries[0];
if (!firstEntry) {
return;
}
this.emit('resize', firstEntry.contentRect);
};
const observer = new ResizeObserver(onResize);
observer.observe(element);
this.emit('changeView', this);
return () => {
observer.disconnect();
this._element = null;
};
}
public onResize = (cb: (rect: IGenericRect) => void): OffCallbackHandler => {
return this.on('resize', cb);
};
public onChangeView = (cb: (element: IBoundingElement) => void): OffCallbackHandler => {
return this.on('changeView', cb);
};
public getElementRect(): DOMRect | null {
if (!this._element) {
return null;
}
return this._element.getBoundingClientRect();
}
}
type DraggableDomElementEvents = {
changeView: IDraggableElement;
} & Pick<DraggableElementEvents, 'move' | 'release' | 'resize'>;
class DraggableDomElement extends Emitter<DraggableDomElementEvents> implements IDraggableElement {
private element: HTMLElement | null = null;
public setElement(element: HTMLElement) {
this.element = element;
const onEnd = () => {
const elementRect = this.getElementRect();
if (!elementRect) {
return;
}
this.emit('release', elementRect);
};
const onMove = (event: PointerEvent) => {
this.emit('move', getPointerEventCoordinates(event));
};
const onResize = (entries: ResizeObserverEntry[]) => {
const firstEntry = entries[0];
if (!firstEntry) {
return;
}
this.emit('resize', firstEntry.contentRect);
};
const unsubArray: OffCallbackHandler[] = [];
// Attach MOVE DOM listeners
unsubArray.push(
...MOVE_DOM_EVENTS.map((event) => {
window.addEventListener(event, onMove);
return () => window.removeEventListener(event, onMove);
}),
);
// Attach RELEASE DOM listeners
unsubArray.push(
...RELEASE_DOM_EVENTS.map((event) => {
window.addEventListener(event, onEnd);
return () => window.removeEventListener(event, onEnd);
}),
);
const observer = new ResizeObserver(onResize);
observer.observe(element);
unsubArray.push(() => observer.disconnect());
this.emit('changeView', this);
return () => {
this.element = null;
unsubArray.forEach((unsub) => unsub());
};
}
public onResize = (cb: (rect: IGenericRect) => void): OffCallbackHandler => {
return this.on('resize', cb);
};
public onChangeView = (cb: (element: IDraggableElement) => void): OffCallbackHandler => {
return this.on('changeView', cb);
};
public onMove = (cb: (pointerPosition: IPointCoordinates) => void): OffCallbackHandler => {
return this.on('move', cb);
};
public onRelease = (cb: (rect: IGenericRect) => void): OffCallbackHandler => {
return this.on('release', cb);
};
public getElementRect(): DOMRect | null {
if (!this.element) {
return null;
}
return this.element.getBoundingClientRect();
}
public setElementPositionOffset(offset: IPointCoordinates): void {
if (!this.element) {
return;
}
this.element.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
}
}
type useDraggableOptions = {
restorePosition?: IGenericRect | null;
onChangePosition?: (position: IGenericRect) => void;
};
export const useDraggable = (options?: useDraggableOptions) => {
const { restorePosition, onChangePosition } = options || {};
const [draggableElement] = useState<Draggable>(() => new Draggable(new DraggableDomElement()));
const [boundingElement] = useState<BoundingElement>(() => new BoundingElement(new BoundingDomElement(), draggableElement));
const [handleElement] = useState<HandleElement>(() => new HandleElement(new HandleDomElement(), draggableElement));
const restorePositionRef = useRef<IGenericRect | null>(restorePosition || null);
const handleElementCallbackRef = useSafeRefCallback(
useCallback(
(node: HTMLElement) => {
return handleElement.element.setElement(node);
},
[handleElement],
),
);
const draggableCallbackRef = useSafeRefCallback(
useCallback(
(node: HTMLElement) => {
const offMove = draggableElement.onMove(() => {
const position = node.getBoundingClientRect();
restorePositionRef.current = position;
if (onChangePosition) {
onChangePosition(position);
}
});
const offDomEvents = draggableElement.element.setElement(node);
if (restorePositionRef.current) {
draggableElement.moveToCoordinates(restorePositionRef.current, node.getBoundingClientRect());
}
return () => {
offDomEvents();
offMove();
};
},
[draggableElement, onChangePosition],
),
);
const boundingCallbackRef = useSafeRefCallback(
useCallback(
(node: HTMLElement) => {
return boundingElement.element.setElement(node);
},
[boundingElement],
),
);
return [draggableCallbackRef, boundingCallbackRef, handleElementCallbackRef];
};

View File

@@ -0,0 +1,534 @@
import { Box, Button } from '@rocket.chat/fuselage';
import { AnchorPortal } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { useEffect, useLayoutEffect, useState, type Ref } from 'react';
import { within, fireEvent, waitFor, expect, userEvent } from 'storybook/test';
import { useDraggable, DEFAULT_BOUNDING_ELEMENT_OPTIONS } from './DraggableCore';
class BoundingBoxResizeEvent extends Event {
constructor(
public width: number,
public height: number,
) {
super('BoundingBoxResize');
}
get size() {
return {
width: this.width,
height: this.height,
};
}
}
const isBoundingBoxResizeEvent = (event: unknown): event is BoundingBoxResizeEvent => {
return event instanceof BoundingBoxResizeEvent;
};
const BOUNDING_SIZE = 1000;
const DraggableElement = ({
draggableRef,
handleRef,
onClick,
onClickResize,
backgroundColor,
height,
width,
}: {
draggableRef: Ref<HTMLElement>;
handleRef: Ref<HTMLElement>;
onClick?: () => void;
onClickResize?: () => void;
backgroundColor?: string;
height?: number;
width?: number;
}) => {
return (
<Box
display='block'
ref={draggableRef}
id='draggable-box'
width={width ?? 300}
height={height ?? 250}
backgroundColor={backgroundColor ?? 'blue'}
data-testid='draggable-box'
>
{/* This text inside the handle is necessary so that the text selection test doesn't fail due to having no text nodes */}
<Box ref={handleRef} id='drag-handle' width='100%' height={40} backgroundColor='red' data-testid='drag-handle'>
Handle
</Box>
{!!onClick && (
<Button data-testid='change-view' onClick={onClick}>
Change view
</Button>
)}
{!!onClickResize && (
<Button data-testid='resize-box' onClick={onClickResize}>
Resize box
</Button>
)}
</Box>
);
};
const DraggableBase = ({
boundingRef,
draggableRef,
handleRef,
onClick,
onClickResize,
backgroundColor,
height,
width,
}: {
boundingRef: Ref<HTMLElement>;
draggableRef: Ref<HTMLElement>;
handleRef: Ref<HTMLElement>;
onClick?: () => void;
onClickResize?: () => void;
backgroundColor?: string;
height?: number;
width?: number;
}) => {
const [boundingSize, setBoundingSize] = useState<{ width: number; height: number }>({
width: BOUNDING_SIZE,
height: BOUNDING_SIZE,
});
useEffect(() => {
const handleBoundingBoxResize = (event: unknown) => {
if (isBoundingBoxResizeEvent(event)) {
setBoundingSize(event.size);
}
};
window.addEventListener('BoundingBoxResize', handleBoundingBoxResize);
return () => {
window.removeEventListener('BoundingBoxResize', handleBoundingBoxResize);
};
}, []);
return (
<Box
display='block'
ref={boundingRef}
backgroundColor='gray'
id='bounding-box'
position='fixed'
width={boundingSize?.width ?? 'full'}
height={boundingSize?.height ?? 'full'}
style={{ overflow: 'hidden' }}
data-testid='bounding-box'
>
<DraggableElement
draggableRef={draggableRef}
handleRef={handleRef}
onClick={onClick}
onClickResize={onClickResize}
backgroundColor={backgroundColor}
height={height}
width={width}
/>
</Box>
);
};
const DraggableBox = () => {
const [draggableRef, boundingRef, handleRef] = useDraggable();
return <DraggableBase boundingRef={boundingRef} draggableRef={draggableRef} handleRef={handleRef} />;
};
const SECONDARY_DRAGGABLE_BOX_SIZE = 600;
// Component for testing position restore when element is replaced
// E.g. When a state changes the rendered component, the new component should have the same positioning
const DraggableBoxWrapper = () => {
const [draggableRef, boundingRef, handleRef] = useDraggable();
const [view, setView] = useState<'initial' | 'updated' | 'resized'>('initial');
const [height, setHeight] = useState<number | undefined>(undefined);
const [width, setWidth] = useState<number | undefined>(undefined);
const onClick = () => {
setView(view === 'initial' ? 'updated' : 'initial');
};
const onClickResize = () => {
const getSize = (previous: number | undefined) => {
if (typeof previous === 'undefined') {
return SECONDARY_DRAGGABLE_BOX_SIZE;
}
return undefined;
};
setHeight((prevHeight) => getSize(prevHeight));
setWidth((prevWidth) => getSize(prevWidth));
};
const bgColor = view === 'initial' ? undefined : 'green';
return (
<DraggableBase
backgroundColor={bgColor}
key={view}
boundingRef={boundingRef}
draggableRef={draggableRef}
handleRef={handleRef}
onClick={onClick}
onClickResize={onClickResize}
height={height}
width={width}
/>
);
};
// Story for manual testing
export const DraggableBoxWithControls: Story = {
render: () => <DraggableBoxWrapper />,
};
const meta = {
component: DraggableBox,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof DraggableBox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
// TODO: test other pointer types (pen, touch, etc)
// TODO: use `userEvent` for all tests
const moveHelper = async (handle: HTMLElement, offset: { x: number; y: number }) => {
const handleRect = handle.getBoundingClientRect();
// Grab the middle of the handle
const startX = handleRect.left + handleRect.width / 2;
const startY = handleRect.top + handleRect.height / 2;
await fireEvent.pointerDown(handle, {
clientX: startX,
clientY: startY,
});
await fireEvent.pointerMove(document.documentElement, {
clientX: startX + offset.x,
clientY: startY + offset.y,
});
await fireEvent.pointerUp(document.documentElement);
};
export const DraggingBehavior: Story = {
...Default,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement.parentElement || canvasElement);
await step('should allow dragging an element', async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
await expect(handle).toBeInTheDocument();
await expect(draggable).toBeInTheDocument();
const initialRect = draggable.getBoundingClientRect();
await moveHelper(handle, { x: 100, y: 100 });
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
await expect(finalRect.x).toBeCloseTo(initialRect.x + 100, 0);
await expect(finalRect.y).toBeCloseTo(initialRect.y + 100, 0);
});
});
await step('should only allow dragging when using the handle element', async () => {
const draggable = await canvas.findByTestId('draggable-box');
const initialRect = draggable.getBoundingClientRect();
await moveHelper(draggable, { x: 100, y: 100 });
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
await expect(finalRect.x).toBeCloseTo(initialRect.x, 0);
await expect(finalRect.y).toBeCloseTo(initialRect.y, 0);
});
});
await step('should not allow dragging with right click', async () => {
const draggable = await canvas.findByTestId('draggable-box');
const handle = await canvas.findByTestId('drag-handle');
const initialRect = draggable.getBoundingClientRect();
// for some reason `fireEvent` and `userEvent` do not change the `button` property of the event
// so we need to create a new event manually
await fireEvent(
handle,
new PointerEvent('pointerdown', {
button: 2,
pointerType: 'mouse',
}),
);
await fireEvent.pointerMove(document.documentElement, {
clientX: 50,
clientY: 50,
});
await fireEvent.pointerUp(document.documentElement);
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
await expect(finalRect.x).toBeCloseTo(initialRect.x, 0);
await expect(finalRect.y).toBeCloseTo(initialRect.y, 0);
});
});
},
};
export const BoundingBehavior: Story = {
...Default,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement.parentElement || canvasElement);
await step('should keep element within screen bounds when dragged outside (top-left)', async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
const boundingBox = await canvas.findByTestId('bounding-box');
await moveHelper(handle, { x: -500, y: -500 });
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
const boundingBoxRect = boundingBox.getBoundingClientRect();
await expect(finalRect.left).toBeGreaterThanOrEqual(boundingBoxRect.left);
await expect(finalRect.top).toBeGreaterThanOrEqual(boundingBoxRect.top);
});
});
await step('should keep element within screen bounds when dragged outside (bottom-right)', async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
const boundingBox = await canvas.findByTestId('bounding-box');
await moveHelper(handle, { x: BOUNDING_SIZE + 500, y: BOUNDING_SIZE + 500 });
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
const currentBoundingBoxRect = boundingBox.getBoundingClientRect();
await expect(finalRect.right).toBeLessThanOrEqual(currentBoundingBoxRect.right + 1);
await expect(finalRect.bottom).toBeLessThanOrEqual(currentBoundingBoxRect.bottom + 1);
});
});
await step('should adjust element position when screen is resized and element is cut off', async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
const boundingBox = await canvas.findByTestId('bounding-box');
const initialRect = boundingBox.getBoundingClientRect();
await moveHelper(handle, { x: BOUNDING_SIZE - initialRect.width, y: BOUNDING_SIZE - initialRect.height });
await waitFor(async () => {
const rect = draggable.getBoundingClientRect();
await expect(rect.right).toBeCloseTo(initialRect.right, 0);
await expect(rect.bottom).toBeCloseTo(initialRect.bottom, 0);
});
window.dispatchEvent(new BoundingBoxResizeEvent(500, 500));
await waitFor(async () => {
const finalRect = draggable.getBoundingClientRect();
const newBoundingBoxRect = boundingBox.getBoundingClientRect();
await expect(newBoundingBoxRect.width).toBeCloseTo(500, 0);
await expect(newBoundingBoxRect.height).toBeCloseTo(500, 0);
await expect(finalRect.right).toBeCloseTo(newBoundingBoxRect.right, 0);
await expect(finalRect.bottom).toBeCloseTo(newBoundingBoxRect.bottom, 0);
});
});
},
};
export const ElementUpdates: Story = {
render: () => <DraggableBoxWrapper />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement.parentElement || canvasElement);
await step('should maintain position when element is replaced', async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
const initialRect = draggable.getBoundingClientRect();
await moveHelper(handle, { x: 50, y: 50 });
let positionBeforeRerender: DOMRect = new DOMRect(0, 0, 0, 0);
await waitFor(async () => {
positionBeforeRerender = draggable.getBoundingClientRect();
await expect(positionBeforeRerender.x).toBeCloseTo(initialRect.x + 50, 0);
await expect(positionBeforeRerender.y).toBeCloseTo(initialRect.y + 50, 0);
});
await fireEvent.click(await canvas.findByTestId('change-view'));
const currentDraggable = await canvas.findByTestId('draggable-box');
const positionAfterDrag = currentDraggable.getBoundingClientRect();
await expect(positionAfterDrag.x).toBeCloseTo(positionBeforeRerender.x, 0);
await expect(positionAfterDrag.y).toBeCloseTo(positionBeforeRerender.y, 0);
});
await step("should maintain position but keep element within bounds if it's size changes", async () => {
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
// wait for bounding box to emit the initial resize event
await waitFor(() => new Promise((resolve) => setTimeout(resolve, DEFAULT_BOUNDING_ELEMENT_OPTIONS.resizeDebounce)));
const initialRect = draggable.getBoundingClientRect();
await moveHelper(handle, { x: BOUNDING_SIZE - initialRect.width, y: BOUNDING_SIZE - initialRect.height });
let positionBeforeRerender: DOMRect = new DOMRect(0, 0, 0, 0);
await waitFor(async () => {
positionBeforeRerender = draggable.getBoundingClientRect();
await expect(positionBeforeRerender.x).toBeCloseTo(BOUNDING_SIZE - initialRect.width, 0);
await expect(positionBeforeRerender.y).toBeCloseTo(BOUNDING_SIZE - initialRect.height, 0);
});
await fireEvent.click(await canvas.findByTestId('resize-box'));
const currentDraggable = await canvas.findByTestId('draggable-box');
await waitFor(async () => {
const positionAfterDrag = currentDraggable.getBoundingClientRect();
await expect(positionAfterDrag.x).toBeCloseTo(BOUNDING_SIZE - SECONDARY_DRAGGABLE_BOX_SIZE, 0);
await expect(positionAfterDrag.y).toBeCloseTo(BOUNDING_SIZE - SECONDARY_DRAGGABLE_BOX_SIZE, 0);
});
});
},
};
const getLorem = (size: number) => {
return Array.from({ length: size }, (_, i) => (
<Box display='block' paddingBlockEnd={12} key={i}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum.
</Box>
));
};
// Mocked page with scrollable content and text
const MockedPage = () => {
const [draggableRef, boundingRef, handleRef] = useDraggable();
useLayoutEffect(() => {
boundingRef(document.body);
// Storybook root grows to wrap the content
// This is needed in order to mock a page with a scrollbar
// There might be a better solution but it
const root = document.getElementById('storybook-root');
if (root) {
root.style.width = '100%';
root.style.height = '100%';
}
}, [boundingRef]);
const overflowBoxProps = {
display: 'flex',
height: 'full',
flexGrow: 1,
flexShrink: 0,
flexBasis: '33%',
overflowY: 'scroll',
overflowX: 'hidden',
} as const;
return (
<>
<Box display='flex' flexDirection='row' height='100vh' width='100vw' overflow='hidden'>
<Box {...overflowBoxProps}>
<Box width='full' height='full' data-testid='scrollable-1'>
{getLorem(100)}
</Box>
</Box>
<Box {...overflowBoxProps}>
<Box width='full' height='full' data-testid='scrollable-2'>
{getLorem(100)}
</Box>
</Box>
<Box {...overflowBoxProps}>
<Box width='full' height='full' data-testid='scrollable-3'>
{getLorem(100)}
</Box>
</Box>
</Box>
<AnchorPortal id='rcx-draggable-anchor-portal'>
<DraggableElement draggableRef={draggableRef} handleRef={handleRef} />
</AnchorPortal>
</>
);
};
export const ScrollablePage: Story = {
render: () => <MockedPage />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement.parentElement || canvasElement);
await step('should not allow selecting text when dragging an element', async () => {
const user = userEvent.setup({ delay: 0 });
const handle = await canvas.findByTestId('drag-handle');
const draggable = await canvas.findByTestId('draggable-box');
await expect(handle).toBeInTheDocument();
await expect(draggable).toBeInTheDocument();
const handleRect = handle.getBoundingClientRect();
// Grab the middle of the handle
const startX = handleRect.left + handleRect.width / 2;
const startY = handleRect.top + handleRect.height / 2;
// Caveat: We use "user-select: none" to prevent text selection behind the widget
// but when simulating mouse events with `userEvent`, the text selection is triggered
// regardless of the "user-select: none" property
// This most likely happens because the events are simulated and not a real mouse interaction
// This means we cannot test the text behind the widget, but we can test if the text inside the handle was selected
// During manual tests, either both texts (inside and behind the widget) are selected or neither is selected
// So this test should cover the issue even though it's not a perfect solution
await user.pointer([
{
target: handle,
keys: '[MouseLeft>]',
coords: {
x: startX,
y: startY,
},
},
{
// we need to set offset to trigger text selection
// this is what makes testing difficult, as setting the offset and target to "document.documentElement"
// always triggers text selection, regardless of the css styles
offset: 0,
pointerName: 'mouse',
coords: {
x: startX,
y: -10,
},
},
{
keys: '[/MouseLeft]',
},
]);
await expect(document.getSelection()?.toString()).toBe('');
});
},
};

View File

@@ -0,0 +1,4 @@
export * from './useDevicePermissionPrompt';
export { useDraggable } from './VoipPopupDraggable/DraggableCore';
export { useMediaCallAction } from './useMediaCallAction';
export { useMediaCallOpenRoomTracker } from './useMediaCallOpenRoomTracker';

View File

@@ -0,0 +1,206 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { ModalProvider, ModalRegion } from '@rocket.chat/ui-client';
import { renderHook, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { PermissionRequestCancelledCallRejectedError, useDevicePermissionPrompt2 } from './useDevicePermissionPrompt';
const types = ['device-change', 'outgoing', 'incoming'] as const;
const modalWrapper = (children: ReactNode) => {
return (
<ModalProvider>
<ModalRegion />
{children}
</ModalProvider>
);
};
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
VoIP_device_permission_required: 'Mic/speaker access required',
VoIP_allow_and_call: 'Allow and call',
VoIP_allow_and_accept: 'Allow and accept',
Continue_without_mic: 'Continue without mic',
Call_without_mic: 'Call without mic',
Accept_without_mic: 'Accept without mic',
Cancel: 'Cancel',
Allow: 'Allow',
VoIP_device_permission_required_description:
'Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.',
})
.wrap(modalWrapper);
Object.defineProperty(global.navigator, 'mediaDevices', {
value: {
getUserMedia: jest.fn(async () => ({ getTracks: () => [] })),
},
});
describe.each(types)('useDevicePermissionPrompt2 - Action: %s', (actionType) => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Should immediately resolve with stream (permission granted)', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt2(), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'granted' } as PermissionStatus).build(),
});
let stream: MediaStream | undefined;
await act(async () => {
stream = await result.current({ actionType });
});
await waitFor(() => {
expect(stream).toBeDefined();
expect(result.current).toBeInstanceOf(Function);
});
});
it('Should open "denied" modal (permission denied)', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt2(), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'denied' } as PermissionStatus).build(),
});
let rejectPromise!: Promise<MediaStream>;
act(() => {
rejectPromise = result.current({ actionType });
});
// eslint-disable-next-line jest/valid-expect
const rejectionExpectation = expect(rejectPromise).rejects.toThrow(PermissionRequestCancelledCallRejectedError);
const cancel = await screen.findByText('Continue without mic');
expect(cancel).toBeInTheDocument();
await userEvent.click(cancel);
await rejectionExpectation;
expect(cancel).not.toBeInTheDocument();
});
});
describe('useDevicePermissionPrompt2 - Permission state: prompt', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Should open "incoming - prompt" modal and resolve/reject respectively', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt2(), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
let acceptPromise!: Promise<MediaStream>;
act(() => {
acceptPromise = result.current({ actionType: 'incoming' });
});
const accept = await screen.findByText('Allow and accept');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Accept without mic')).toBeInTheDocument();
await userEvent.click(accept);
const stream = await acceptPromise;
expect(stream).toBeDefined();
expect(screen.queryByText('Allow and accept')).not.toBeInTheDocument();
jest.clearAllMocks();
let rejectPromise!: Promise<MediaStream>;
act(() => {
rejectPromise = result.current({ actionType: 'incoming' });
});
// eslint-disable-next-line jest/valid-expect
const rejectionExpectation = expect(rejectPromise).rejects.toThrow(PermissionRequestCancelledCallRejectedError);
const cancel = await screen.findByText('Accept without mic');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow and accept')).toBeInTheDocument();
await userEvent.click(cancel);
await rejectionExpectation;
expect(cancel).not.toBeInTheDocument();
});
it('Should open "outgoing - prompt" modal and resolve / close on cancel', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt2(), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
let acceptPromise!: Promise<MediaStream>;
act(() => {
acceptPromise = result.current({ actionType: 'outgoing' });
});
const accept = await screen.findByText('Allow and call');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Call without mic')).toBeInTheDocument();
await userEvent.click(accept);
const stream = await acceptPromise;
expect(stream).toBeDefined();
expect(screen.queryByText('Allow and call')).not.toBeInTheDocument();
jest.clearAllMocks();
let rejectPromise!: Promise<MediaStream>;
act(() => {
rejectPromise = result.current({ actionType: 'outgoing' });
});
// eslint-disable-next-line jest/valid-expect
const rejectionExpectation = expect(rejectPromise).rejects.toThrow(PermissionRequestCancelledCallRejectedError);
const cancel = await screen.findByText('Call without mic');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow and call')).toBeInTheDocument();
await userEvent.click(cancel);
await rejectionExpectation;
expect(cancel).not.toBeInTheDocument();
});
it('Should open "device-change - prompt" modal and resolve / close on cancel', async () => {
const { result } = renderHook(() => useDevicePermissionPrompt2(), {
wrapper: appRoot.withMicrophonePermissionState({ state: 'prompt' } as PermissionStatus).build(),
});
let acceptPromise!: Promise<MediaStream>;
act(() => {
acceptPromise = result.current({ actionType: 'device-change' });
});
const accept = await screen.findByText('Allow');
expect(accept).toBeInTheDocument();
expect(await screen.findByText('Cancel')).toBeInTheDocument();
await userEvent.click(accept);
const stream = await acceptPromise;
expect(stream).toBeDefined();
expect(screen.queryByText('Allow')).not.toBeInTheDocument();
jest.clearAllMocks();
let rejectPromise!: Promise<MediaStream>;
act(() => {
rejectPromise = result.current({ actionType: 'device-change' });
});
// eslint-disable-next-line jest/valid-expect
const rejectionExpectation = expect(rejectPromise).rejects.toThrow(PermissionRequestCancelledCallRejectedError);
const cancel = await screen.findByText('Cancel');
expect(cancel).toBeInTheDocument();
expect(await screen.findByText('Allow')).toBeInTheDocument();
await userEvent.click(cancel);
await rejectionExpectation;
expect(cancel).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,166 @@
import { useMediaDeviceMicrophonePermission, useSelectedDevices, useSetInputMediaDevice, useSetModal } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { PermissionFlowModal, type PermissionFlowModalType } from '../views';
type OnAccept = (stream: MediaStream) => void;
type OnReject = (error?: DOMException) => void;
type DeviceChangePromptProps = {
onAccept: OnAccept;
onReject?: OnReject;
actionType: 'device-change';
};
type OutgoingPromptProps = {
onAccept: OnAccept;
onReject?: OnReject;
actionType: 'outgoing';
};
type IncomingPromptProps = {
onAccept: OnAccept;
onReject: OnReject;
actionType: 'incoming';
};
type UseDevicePermissionPromptProps = DeviceChangePromptProps | OutgoingPromptProps | IncomingPromptProps;
const isNoDeviceError = (error: Error) => {
return ['NotFoundError', 'DevicesNotFoundError'].includes(error.name);
};
const getModalType = (
actionType: UseDevicePermissionPromptProps['actionType'],
state: Exclude<PermissionState, 'granted'>,
): PermissionFlowModalType => {
if (state === 'denied') {
return 'denied';
}
if (actionType === 'device-change') {
return 'deviceChangePrompt';
}
if (actionType === 'outgoing') {
return 'outgoingPrompt';
}
// actionType === 'incoming'
return 'incomingPrompt';
};
export const stopTracks = (stream: MediaStream) => {
stream.getTracks().forEach((track) => {
track.stop();
});
};
export class PermissionRequestCancelledCallRejectedError extends Error {
constructor(message: string) {
super(message);
this.name = 'PermissionRequestDeniedError';
}
}
export const useDevicePermissionPrompt2 = () => {
const { state, requestDevice } = useMediaDeviceMicrophonePermission();
const setModal = useSetModal();
const setInputMediaDevice = useSetInputMediaDevice();
const queryClient = useQueryClient();
const { audioInput } = useSelectedDevices() || {};
const selectedDeviceId = audioInput?.id;
return useCallback(
async ({
constraints: _constraints,
actionType,
}: {
constraints?: MediaStreamConstraints;
actionType: 'outgoing' | 'incoming' | 'device-change';
}) => {
return new Promise<MediaStream>((_resolve, reject) => {
const resolve = (stream: MediaStream) => {
// Since we now have requested a stream, we can now invalidate the devices list and generate a complete one.
// Obs2: Safari does not seem to be dispatching the change event when permission is granted, so we need to invalidate the permission query as well.
void queryClient.invalidateQueries({ queryKey: ['media-devices-list'] });
_resolve(stream);
};
const onAccept = (stream: MediaStream) => {
stream.getTracks().forEach((track) => {
const { deviceId } = track.getSettings();
if (!deviceId) {
return;
}
if (track.kind === 'audio' && navigator.mediaDevices.enumerateDevices) {
void navigator.mediaDevices.enumerateDevices().then((devices) => {
const device = devices.find((device) => device.deviceId === deviceId);
if (!device) {
return;
}
setInputMediaDevice({
id: device.deviceId,
label: device.label,
type: 'audioinput',
});
});
}
});
resolve(stream);
};
const constraints = _constraints || {
audio: selectedDeviceId ? { deviceId: { exact: selectedDeviceId } } : true,
};
const onConfirm = () => {
void requestDevice?.({
onReject: (...args) => {
const [error] = args;
if (isNoDeviceError(error)) {
setModal(<PermissionFlowModal type='noDevices' onCancel={onCancel} onConfirm={onCancel} />);
return;
}
setModal(<PermissionFlowModal type='denied' onCancel={onCancel} onConfirm={onCancel} />);
},
onAccept: (...args) => {
onAccept(...args);
setModal(null);
},
constraints,
});
};
const onCancel = () => {
reject(new PermissionRequestCancelledCallRejectedError('Permission request modal closed'));
setModal(null);
};
if (state === 'granted') {
void requestDevice({
onAccept: resolve,
onReject: (error) => {
if (isNoDeviceError(error)) {
setModal(<PermissionFlowModal type='noDevices' onCancel={onCancel} onConfirm={onCancel} />);
return;
}
reject(error);
},
constraints,
});
return;
}
const modalType = getModalType(actionType, state);
setModal(<PermissionFlowModal type={modalType} onCancel={onCancel} onConfirm={onConfirm} />);
});
},
[selectedDeviceId, state, setModal, queryClient, setInputMediaDevice, requestDevice],
);
};

View File

@@ -0,0 +1,16 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { IceServer } from '../definitions';
import { parseStringToIceServers } from '../utils/parseStringToIceServers';
export const useIceServers = (): IceServer[] => {
const servers = useSetting('VoIP_TeamCollab_Ice_Servers');
return useMemo(() => {
if (typeof servers !== 'string' || !servers.trim()) {
return [];
}
return parseStringToIceServers(servers);
}, [servers]);
};

View File

@@ -0,0 +1,244 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, renderHook } from '@testing-library/react';
import type { PeerInfo } from '../context';
import { useMediaCallAction } from './useMediaCallAction';
const toggleWidgetMock = jest.fn();
const endCallMock = jest.fn();
const usePeekMediaSessionStateMock = jest.fn();
const usePeekMediaSessionPeerInfoMock = jest.fn();
jest.mock('../context/useWidgetExternalControls', () => ({
useWidgetExternalControls: jest.fn().mockReturnValue({
toggleWidget: (...args: unknown[]) => toggleWidgetMock(...args),
endCall: () => endCallMock(),
}),
}));
jest.mock('../context/usePeekMediaSessionState', () => ({
usePeekMediaSessionState: () => usePeekMediaSessionStateMock(),
}));
jest.mock('../context/usePeekMediaSessionPeerInfo', () => ({
usePeekMediaSessionPeerInfo: () => usePeekMediaSessionPeerInfoMock(),
}));
const createWrapper = () =>
mockAppRoot()
.withTranslations('en', 'core', {
Voice_call__user__hangup: 'Hang up call with {{user}}',
Voice_call__user__cancel: 'Cancel call with {{user}}',
Voice_call__user__reject: 'Reject call from {{user}}',
Voice_call__user_: 'Voice call {{user}}',
New_voice_call: 'New voice call',
})
.build();
describe('useMediaCallAction', () => {
beforeEach(() => {
jest.clearAllMocks();
usePeekMediaSessionStateMock.mockReturnValue('available');
usePeekMediaSessionPeerInfoMock.mockReturnValue(undefined);
});
it('returns undefined when state is unavailable', () => {
usePeekMediaSessionStateMock.mockReturnValue('unavailable');
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current).toBeUndefined();
});
describe('when state is ongoing and peerInfo is set', () => {
beforeEach(() => {
usePeekMediaSessionStateMock.mockReturnValue('ongoing');
usePeekMediaSessionPeerInfoMock.mockReturnValue({ displayName: 'Alice', userId: 'alice-id' });
});
it('returns hangup action with displayName in title', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current).toEqual({
title: 'Hang up call with Alice',
icon: 'phone-off',
action: expect.any(Function),
});
});
it('calls endCall when action is invoked', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
act(() => {
result.current?.action();
});
expect(endCallMock).toHaveBeenCalledTimes(1);
});
it('uses number when peerInfo has no displayName (external peer)', () => {
usePeekMediaSessionPeerInfoMock.mockReturnValue({ number: '+5511999999999' });
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current?.title).toBe('Hang up call with +5511999999999');
});
});
describe('when state is calling and peerInfo is set', () => {
beforeEach(() => {
usePeekMediaSessionStateMock.mockReturnValue('calling');
usePeekMediaSessionPeerInfoMock.mockReturnValue({ displayName: 'Bob', userId: 'bob-id' });
});
it('returns cancel action with displayName in title', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current).toEqual({
title: 'Cancel call with Bob',
icon: 'phone-off',
action: expect.any(Function),
});
});
it('calls endCall when action is invoked', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
act(() => {
result.current?.action();
});
expect(endCallMock).toHaveBeenCalledTimes(1);
});
it('uses number when peerInfo has no displayName (external peer)', () => {
usePeekMediaSessionPeerInfoMock.mockReturnValue({ number: '+5511999999999' });
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current?.title).toBe('Cancel call with +5511999999999');
});
});
describe('when state is ringing and peerInfo is set', () => {
beforeEach(() => {
usePeekMediaSessionStateMock.mockReturnValue('ringing');
usePeekMediaSessionPeerInfoMock.mockReturnValue({ displayName: 'Carol', userId: 'carol-id' });
});
it('returns reject action with displayName in title', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current).toEqual({
title: 'Reject call from Carol',
icon: 'phone-off',
action: expect.any(Function),
});
});
it('calls endCall when action is invoked', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
act(() => {
result.current?.action();
});
expect(endCallMock).toHaveBeenCalledTimes(1);
});
it('uses number when peerInfo has no displayName (external peer)', () => {
usePeekMediaSessionPeerInfoMock.mockReturnValue({ number: '+5511999999999' });
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current?.title).toBe('Reject call from +5511999999999');
});
});
describe('when callee is provided (available state)', () => {
it('returns voice call action with callee displayName in title', () => {
const callee: PeerInfo = { displayName: 'Dave', userId: 'dave-id' };
const { result } = renderHook(() => useMediaCallAction(callee), {
wrapper: createWrapper(),
});
expect(result.current).toEqual({
title: 'Voice call Dave',
icon: 'phone',
action: expect.any(Function),
});
});
it('calls toggleWidget with callee when action is invoked', () => {
const callee: PeerInfo = { displayName: 'Dave', userId: 'dave-id' };
const { result } = renderHook(() => useMediaCallAction(callee), {
wrapper: createWrapper(),
});
act(() => {
result.current?.action(callee);
});
expect(toggleWidgetMock).toHaveBeenCalledWith(callee);
});
it('uses number when callee has no displayName (external peer)', () => {
const callee: PeerInfo = { number: '+5511888888888' };
const { result } = renderHook(() => useMediaCallAction(callee), {
wrapper: createWrapper(),
});
expect(result.current?.title).toBe('Voice call +5511888888888');
});
});
describe('when no active call and no callee (default)', () => {
it('returns new voice call action', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
expect(result.current).toEqual({
title: 'New voice call',
icon: 'dialpad',
action: expect.any(Function),
});
});
it('calls toggleWidget with undefined when action is invoked', () => {
const { result } = renderHook(() => useMediaCallAction(), {
wrapper: createWrapper(),
});
act(() => {
result.current?.action();
});
expect(toggleWidgetMock).toHaveBeenCalledWith(undefined);
});
});
});

View File

@@ -0,0 +1,66 @@
import type { Keys as IconNames } from '@rocket.chat/icons';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { PeerInfo } from '../context';
import { usePeekMediaSessionPeerInfo } from '../context/usePeekMediaSessionPeerInfo';
import { usePeekMediaSessionState } from '../context/usePeekMediaSessionState';
import { useWidgetExternalControls } from '../context/useWidgetExternalControls';
export const useMediaCallAction = (
callee?: PeerInfo,
): { title: string; icon: IconNames; action: (callee?: PeerInfo) => void } | undefined => {
const { t } = useTranslation();
const { toggleWidget, endCall } = useWidgetExternalControls();
const state = usePeekMediaSessionState();
const peerInfo = usePeekMediaSessionPeerInfo();
return useMemo(() => {
if (state === 'unavailable') {
return undefined;
}
const getDisplayName = (peerInfo: { displayName?: string; number?: string }) => {
return peerInfo.displayName || peerInfo.number;
};
if (state === 'ongoing' && peerInfo) {
return {
title: t('Voice_call__user__hangup', { user: getDisplayName(peerInfo) }),
icon: 'phone-off',
action: () => endCall(),
};
}
if (state === 'calling' && peerInfo) {
return {
title: t('Voice_call__user__cancel', { user: getDisplayName(peerInfo) }),
icon: 'phone-off',
action: () => endCall(),
};
}
if (state === 'ringing' && peerInfo) {
return {
title: t('Voice_call__user__reject', { user: getDisplayName(peerInfo) }),
icon: 'phone-off',
action: () => endCall(),
};
}
if (callee) {
return {
title: t('Voice_call__user_', { user: getDisplayName(callee) }),
icon: 'phone',
action: () => toggleWidget(callee),
};
}
return {
title: t('New_voice_call'),
icon: 'dialpad' as const,
action: () => toggleWidget(undefined),
};
}, [state, t, peerInfo, callee, toggleWidget, endCall]);
};

View File

@@ -0,0 +1,63 @@
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { useMediaCallOpenRoomTracker } from './useMediaCallOpenRoomTracker';
import MockedInstanceProvider from '../providers/MockedInstanceProvider';
const setOpenRoomIdMock = jest.fn();
const createWrapper = () => {
const wrapper = ({ children }: { children?: ReactNode }) => (
<MockedInstanceProvider setOpenRoomId={setOpenRoomIdMock}>{children}</MockedInstanceProvider>
);
return wrapper;
};
describe('useMediaCallOpenRoomTracker', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('calls setOpenRoomId with openRoomId on mount', () => {
renderHook(() => useMediaCallOpenRoomTracker('room-123'), {
wrapper: createWrapper(),
});
expect(setOpenRoomIdMock).toHaveBeenCalledTimes(1);
expect(setOpenRoomIdMock).toHaveBeenCalledWith('room-123');
});
it('calls setOpenRoomId with undefined when openRoomId is undefined', () => {
renderHook(() => useMediaCallOpenRoomTracker(undefined), {
wrapper: createWrapper(),
});
expect(setOpenRoomIdMock).toHaveBeenCalledTimes(1);
expect(setOpenRoomIdMock).toHaveBeenCalledWith(undefined);
});
it('calls setOpenRoomId with undefined on unmount (cleanup)', () => {
const { unmount } = renderHook(() => useMediaCallOpenRoomTracker('room-456'), {
wrapper: createWrapper(),
});
setOpenRoomIdMock.mockClear();
unmount();
expect(setOpenRoomIdMock).toHaveBeenCalledTimes(1);
expect(setOpenRoomIdMock).toHaveBeenCalledWith(undefined);
});
it('calls setOpenRoomId with new openRoomId when openRoomId changes', () => {
const { rerender } = renderHook(({ openRoomId }) => useMediaCallOpenRoomTracker(openRoomId), {
wrapper: createWrapper(),
initialProps: { openRoomId: 'room-first' as string | undefined },
});
expect(setOpenRoomIdMock).toHaveBeenCalledWith('room-first');
rerender({ openRoomId: 'room-second' });
expect(setOpenRoomIdMock).toHaveBeenLastCalledWith('room-second');
});
});

View File

@@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { useMediaCallInstance } from '../context/MediaCallInstanceContext';
export const useMediaCallOpenRoomTracker = (openRoomId?: string) => {
const { setOpenRoomId } = useMediaCallInstance();
useEffect(() => {
setOpenRoomId(openRoomId);
return () => {
setOpenRoomId(undefined);
};
}, [setOpenRoomId, openRoomId]);
};

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef } from 'react';
class TonePlayer {
private audioContext: AudioContext;
private audioElement: HTMLAudioElement;
private gainNode: GainNode;
private filter: BiquadFilterNode;
private destination: MediaStreamAudioDestinationNode;
constructor() {
this.audioContext = new AudioContext();
this.audioElement = new Audio();
// Route audio through an audio element
// In order to be able to set the sink id
this.destination = this.audioContext.createMediaStreamDestination();
this.audioElement.srcObject = this.destination.stream;
// Audio volume control
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 0.5;
// This filter makes the sound more natural
this.filter = this.audioContext.createBiquadFilter();
this.filter.type = 'bandpass';
this.filter.frequency.value = 4000;
this.gainNode.connect(this.filter);
this.filter.connect(this.destination);
}
public async setSinkId(sinkId: string) {
if (this.audioElement.setSinkId) {
return this.audioElement.setSinkId(sinkId);
}
console.warn('setSinkId not supported on this browser');
}
public static setupOscillator(audioCtx: AudioContext, filter: AudioNode) {
const oscillator = audioCtx.createOscillator();
oscillator.type = 'sine';
oscillator.connect(filter);
return oscillator;
}
public play(highFreq: number, lowFreq: number, durationMs?: number) {
const highFrequencyOscillator = TonePlayer.setupOscillator(this.audioContext, this.gainNode);
const lowFrequencyOscillator = TonePlayer.setupOscillator(this.audioContext, this.gainNode);
lowFrequencyOscillator.frequency.value = lowFreq;
highFrequencyOscillator.frequency.value = highFreq;
const startTime = this.audioContext.currentTime;
lowFrequencyOscillator.start(startTime);
highFrequencyOscillator.start(startTime);
// Ensure audio element is playing
if (this.audioElement.paused) {
this.audioElement.play().catch((error) => {
console.warn('Failed to play audio element:', error);
});
}
highFrequencyOscillator.stop(startTime + (durationMs ?? 400) / 1000);
lowFrequencyOscillator.stop(startTime + (durationMs ?? 400) / 1000);
}
public destroy() {
void this.audioContext.close();
this.audioElement.pause();
this.audioElement.srcObject = null;
}
}
const DIGIT_TONE_MAP = {
'1': [1209, 697],
'2': [1336, 697],
'3': [1477, 697],
'4': [1209, 770],
'5': [1336, 770],
'6': [1477, 770],
'7': [1209, 852],
'8': [1336, 852],
'9': [1477, 852],
'*': [1209, 941],
'0': [1336, 941],
'#': [1477, 941],
} as const;
export const isValidTone = (tone: string): tone is keyof typeof DIGIT_TONE_MAP => {
return Object.keys(DIGIT_TONE_MAP).includes(tone);
};
export const useTonePlayer = (sinkId?: string) => {
const tonePlayer = useRef<TonePlayer | null>(null);
useEffect(() => {
tonePlayer.current = new TonePlayer();
return () => tonePlayer.current?.destroy();
}, []);
useEffect(() => {
if (tonePlayer.current && sinkId) {
void tonePlayer.current.setSinkId(sinkId);
}
}, [sinkId]);
const playTone = useCallback(
(digit: keyof typeof DIGIT_TONE_MAP) => {
if (!tonePlayer.current) {
return;
}
tonePlayer.current.play(DIGIT_TONE_MAP[digit][0], DIGIT_TONE_MAP[digit][1], 250);
},
[tonePlayer],
);
return playTone;
};

View File

@@ -0,0 +1,20 @@
export { default as MediaCallProvider } from './providers/MediaCallProvider';
export {
MediaCallInstanceContext,
useWidgetExternalControls,
usePeekMediaSessionState,
usePeekMediaSessionPeerInfo,
usePeekMediaSessionFeatures,
} from './context';
export type { PeekMediaSessionStateReturn } from './context';
export type { PeerInfo } from './context';
export { useMediaCallAction, useMediaCallOpenRoomTracker } from './hooks';
export { CallHistoryContextualBar, MediaCallRoomActivity } from './views';
export type { CallHistoryData } from './views';
export * from './definitions/callHistoryContacts';
export { getHistoryMessagePayload } from './ui-kit/getHistoryMessagePayload';
export * from './views/MediaCallHistoryTable';

Some files were not shown because too many files have changed in this diff Show More