snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
36
packages/ui-voip/src/components/ActionButton.tsx
Normal file
36
packages/ui-voip/src/components/ActionButton.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
40
packages/ui-voip/src/components/Actions/ActionStrip.tsx
Normal file
40
packages/ui-voip/src/components/Actions/ActionStrip.tsx
Normal 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;
|
||||
32
packages/ui-voip/src/components/Actions/ActionToggleChat.tsx
Normal file
32
packages/ui-voip/src/components/Actions/ActionToggleChat.tsx
Normal 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;
|
||||
2
packages/ui-voip/src/components/Actions/index.ts
Normal file
2
packages/ui-voip/src/components/Actions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ActionStrip } from './ActionStrip';
|
||||
export { default as ActionToggleChat } from './ActionToggleChat';
|
||||
26
packages/ui-voip/src/components/CallHistoryExternalUser.tsx
Normal file
26
packages/ui-voip/src/components/CallHistoryExternalUser.tsx
Normal 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;
|
||||
36
packages/ui-voip/src/components/CallHistoryInternalUser.tsx
Normal file
36
packages/ui-voip/src/components/CallHistoryInternalUser.tsx
Normal 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;
|
||||
16
packages/ui-voip/src/components/CallHistoryUnknownUser.tsx
Normal file
16
packages/ui-voip/src/components/CallHistoryUnknownUser.tsx
Normal 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;
|
||||
22
packages/ui-voip/src/components/CallHistoryUser.tsx
Normal file
22
packages/ui-voip/src/components/CallHistoryUser.tsx
Normal 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;
|
||||
87
packages/ui-voip/src/components/Cards/Card.tsx
Normal file
87
packages/ui-voip/src/components/Cards/Card.tsx
Normal 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;
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
41
packages/ui-voip/src/components/Cards/CardList.tsx
Normal file
41
packages/ui-voip/src/components/Cards/CardList.tsx
Normal 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;
|
||||
27
packages/ui-voip/src/components/Cards/CardListContainer.tsx
Normal file
27
packages/ui-voip/src/components/Cards/CardListContainer.tsx
Normal 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;
|
||||
59
packages/ui-voip/src/components/Cards/CardListPinned.tsx
Normal file
59
packages/ui-voip/src/components/Cards/CardListPinned.tsx
Normal 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;
|
||||
27
packages/ui-voip/src/components/Cards/CardListSection.tsx
Normal file
27
packages/ui-voip/src/components/Cards/CardListSection.tsx
Normal 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;
|
||||
60
packages/ui-voip/src/components/Cards/CardSlot.tsx
Normal file
60
packages/ui-voip/src/components/Cards/CardSlot.tsx
Normal 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;
|
||||
@@ -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;
|
||||
46
packages/ui-voip/src/components/Cards/Cards.stories.tsx
Normal file
46
packages/ui-voip/src/components/Cards/Cards.stories.tsx
Normal 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} />,
|
||||
};
|
||||
32
packages/ui-voip/src/components/Cards/PeerCard/PeerCard.tsx
Normal file
32
packages/ui-voip/src/components/Cards/PeerCard/PeerCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
2
packages/ui-voip/src/components/Cards/PeerCard/index.ts
Normal file
2
packages/ui-voip/src/components/Cards/PeerCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as PeerCard } from './PeerCard';
|
||||
export { default as PeerCardSlot } from './PeerCardSlot';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as StreamCard } from './StreamCard';
|
||||
export { default as StreamCardPin } from './StreamCardPin';
|
||||
export { default as StreamCardStopSharingButton } from './StreamCardStopSharingButton';
|
||||
14
packages/ui-voip/src/components/Cards/index.ts
Normal file
14
packages/ui-voip/src/components/Cards/index.ts
Normal 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';
|
||||
13
packages/ui-voip/src/components/Cards/useShouldWrapCards.ts
Normal file
13
packages/ui-voip/src/components/Cards/useShouldWrapCards.ts
Normal 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;
|
||||
};
|
||||
160
packages/ui-voip/src/components/DevicePicker.tsx
Normal file
160
packages/ui-voip/src/components/DevicePicker.tsx
Normal 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;
|
||||
49
packages/ui-voip/src/components/Keypad/Key.tsx
Normal file
49
packages/ui-voip/src/components/Keypad/Key.tsx
Normal 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;
|
||||
21
packages/ui-voip/src/components/Keypad/Keypad.spec.tsx
Normal file
21
packages/ui-voip/src/components/Keypad/Keypad.spec.tsx
Normal 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();
|
||||
});
|
||||
20
packages/ui-voip/src/components/Keypad/Keypad.stories.tsx
Normal file
20
packages/ui-voip/src/components/Keypad/Keypad.stories.tsx
Normal 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)} />;
|
||||
},
|
||||
};
|
||||
43
packages/ui-voip/src/components/Keypad/Keypad.tsx
Normal file
43
packages/ui-voip/src/components/Keypad/Keypad.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
72
packages/ui-voip/src/components/PeerAutocomplete.tsx
Normal file
72
packages/ui-voip/src/components/PeerAutocomplete.tsx
Normal 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;
|
||||
18
packages/ui-voip/src/components/PeerInfo/ExternalUser.tsx
Normal file
18
packages/ui-voip/src/components/PeerInfo/ExternalUser.tsx
Normal 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;
|
||||
51
packages/ui-voip/src/components/PeerInfo/InternalUser.tsx
Normal file
51
packages/ui-voip/src/components/PeerInfo/InternalUser.tsx
Normal 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;
|
||||
19
packages/ui-voip/src/components/PeerInfo/PeerInfo.spec.tsx
Normal file
19
packages/ui-voip/src/components/PeerInfo/PeerInfo.spec.tsx
Normal 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();
|
||||
});
|
||||
@@ -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'
|
||||
/>
|
||||
),
|
||||
};
|
||||
15
packages/ui-voip/src/components/PeerInfo/PeerInfo.tsx
Normal file
15
packages/ui-voip/src/components/PeerInfo/PeerInfo.tsx
Normal 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;
|
||||
16
packages/ui-voip/src/components/PeerInfo/PhoneNumber.tsx
Normal file
16
packages/ui-voip/src/components/PeerInfo/PhoneNumber.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
5
packages/ui-voip/src/components/PeerInfo/index.ts
Normal file
5
packages/ui-voip/src/components/PeerInfo/index.ts
Normal 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';
|
||||
69
packages/ui-voip/src/components/PeerInfo/useInfoSlots.ts
Normal file
69
packages/ui-voip/src/components/PeerInfo/useInfoSlots.ts
Normal 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;
|
||||
};
|
||||
51
packages/ui-voip/src/components/Timer.tsx
Normal file
51
packages/ui-voip/src/components/Timer.tsx
Normal 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;
|
||||
19
packages/ui-voip/src/components/ToggleButton.spec.tsx
Normal file
19
packages/ui-voip/src/components/ToggleButton.spec.tsx
Normal 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();
|
||||
});
|
||||
23
packages/ui-voip/src/components/ToggleButton.stories.tsx
Normal file
23
packages/ui-voip/src/components/ToggleButton.stories.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
60
packages/ui-voip/src/components/ToggleButton.tsx
Normal file
60
packages/ui-voip/src/components/ToggleButton.tsx
Normal 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;
|
||||
19
packages/ui-voip/src/components/Widget/Widget.spec.tsx
Normal file
19
packages/ui-voip/src/components/Widget/Widget.spec.tsx
Normal 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();
|
||||
});
|
||||
37
packages/ui-voip/src/components/Widget/Widget.stories.tsx
Normal file
37
packages/ui-voip/src/components/Widget/Widget.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
30
packages/ui-voip/src/components/Widget/Widget.tsx
Normal file
30
packages/ui-voip/src/components/Widget/Widget.tsx
Normal 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;
|
||||
38
packages/ui-voip/src/components/Widget/WidgetBase.tsx
Normal file
38
packages/ui-voip/src/components/Widget/WidgetBase.tsx
Normal 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;
|
||||
12
packages/ui-voip/src/components/Widget/WidgetContent.tsx
Normal file
12
packages/ui-voip/src/components/Widget/WidgetContent.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
12
packages/ui-voip/src/components/Widget/WidgetFooter.tsx
Normal file
12
packages/ui-voip/src/components/Widget/WidgetFooter.tsx
Normal 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;
|
||||
35
packages/ui-voip/src/components/Widget/WidgetHandle.tsx
Normal file
35
packages/ui-voip/src/components/Widget/WidgetHandle.tsx
Normal 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;
|
||||
26
packages/ui-voip/src/components/Widget/WidgetHeader.tsx
Normal file
26
packages/ui-voip/src/components/Widget/WidgetHeader.tsx
Normal 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;
|
||||
43
packages/ui-voip/src/components/Widget/WidgetInfo.tsx
Normal file
43
packages/ui-voip/src/components/Widget/WidgetInfo.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
6
packages/ui-voip/src/components/Widget/index.ts
Normal file
6
packages/ui-voip/src/components/Widget/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
`;
|
||||
15
packages/ui-voip/src/components/index.ts
Normal file
15
packages/ui-voip/src/components/index.ts
Normal 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';
|
||||
52
packages/ui-voip/src/context/MediaCallInstanceContext.ts
Normal file
52
packages/ui-voip/src/context/MediaCallInstanceContext.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { MediaSignalingSession } from '@rocket.chat/media-signaling';
|
||||
import type { RefObject } from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { PeerAutocompleteOptions } from '../components';
|
||||
import type { PeerInfo } from './definitions';
|
||||
|
||||
export type AvailableViews = 'room' | 'popout' | 'widget';
|
||||
|
||||
type RegisterView = (view: AvailableViews) => void;
|
||||
type UnregisterView = (view: AvailableViews) => void;
|
||||
|
||||
type WidgetVisibility = 'open' | 'closed';
|
||||
|
||||
export type MediaCallInstanceContextValue = {
|
||||
instance: MediaSignalingSession | undefined;
|
||||
audioElement: RefObject<HTMLAudioElement | null> | undefined;
|
||||
openRoomId: string | undefined;
|
||||
|
||||
currentViews: Set<AvailableViews>;
|
||||
registerView: RegisterView;
|
||||
unregisterView: UnregisterView;
|
||||
|
||||
targetWidgetVisibility: WidgetVisibility;
|
||||
targetPeer: PeerInfo | undefined;
|
||||
|
||||
setOpenRoomId: (openRoomId: string | undefined) => void;
|
||||
getAutocompleteOptions: (filter: string) => Promise<PeerAutocompleteOptions[]>;
|
||||
openWidget: (peerInfo?: PeerInfo) => void;
|
||||
closeWidget: () => void;
|
||||
setTargetPeer: (peerInfo?: PeerInfo) => void;
|
||||
};
|
||||
|
||||
export const defaultContextValue = {
|
||||
instance: undefined,
|
||||
audioElement: undefined,
|
||||
openRoomId: undefined,
|
||||
setOpenRoomId: () => undefined,
|
||||
getAutocompleteOptions: () => Promise.resolve([]),
|
||||
currentViews: new Set<AvailableViews>(),
|
||||
registerView: () => undefined,
|
||||
unregisterView: () => undefined,
|
||||
openWidget: () => undefined,
|
||||
closeWidget: () => undefined,
|
||||
setTargetPeer: () => undefined,
|
||||
targetWidgetVisibility: 'closed' as const,
|
||||
targetPeer: undefined,
|
||||
};
|
||||
|
||||
export const MediaCallInstanceContext = createContext<MediaCallInstanceContextValue>(defaultContextValue);
|
||||
|
||||
export const useMediaCallInstance = (): MediaCallInstanceContextValue => useContext(MediaCallInstanceContext);
|
||||
73
packages/ui-voip/src/context/MediaCallViewContext.ts
Normal file
73
packages/ui-voip/src/context/MediaCallViewContext.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { type IMediaStreamWrapper } from '@rocket.chat/media-signaling';
|
||||
import type { Device } from '@rocket.chat/ui-contexts';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { SessionState, PeerInfo } from './definitions';
|
||||
import { type LastKnownPosition } from '../providers/useWidgetPositionTracker';
|
||||
|
||||
export type MediaCallStreams = {
|
||||
remoteScreen?: IMediaStreamWrapper;
|
||||
localScreen?: IMediaStreamWrapper;
|
||||
};
|
||||
|
||||
type MediaCallViewContextValue = {
|
||||
sessionState: SessionState;
|
||||
targetPeer?: PeerInfo;
|
||||
onClickDirectMessage?: () => void;
|
||||
onMute: () => void;
|
||||
onHold: () => void;
|
||||
onDeviceChange: (device: Device) => void;
|
||||
onForward: () => void;
|
||||
onTone: (tone: string) => void;
|
||||
onEndCall: () => void;
|
||||
onCall: () => Promise<void>;
|
||||
onAccept: () => Promise<void>;
|
||||
onSelectPeer: (peerInfo: PeerInfo) => void;
|
||||
onToggleScreenSharing: () => void;
|
||||
onOpenPopout: () => void;
|
||||
onClosePopout: () => void;
|
||||
streams: MediaCallStreams;
|
||||
widgetPositionTracker?: {
|
||||
onChangePosition: (position: LastKnownPosition | null) => void;
|
||||
lastKnownPosition: LastKnownPosition | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultSessionState: SessionState = {
|
||||
state: 'none',
|
||||
connectionState: 'CONNECTING',
|
||||
peerInfo: undefined,
|
||||
transferredBy: undefined,
|
||||
hidden: false,
|
||||
muted: false,
|
||||
held: false,
|
||||
remoteMuted: false,
|
||||
remoteHeld: false,
|
||||
callId: undefined,
|
||||
startedAt: undefined,
|
||||
supportedFeatures: ['audio', 'transfer', 'hold'],
|
||||
};
|
||||
|
||||
export const defaultMediaCallContextValue: MediaCallViewContextValue = {
|
||||
sessionState: defaultSessionState,
|
||||
targetPeer: undefined,
|
||||
onMute: () => undefined,
|
||||
onHold: () => undefined,
|
||||
onDeviceChange: () => undefined,
|
||||
onForward: () => undefined,
|
||||
onTone: () => undefined,
|
||||
onEndCall: () => undefined,
|
||||
onCall: () => Promise.resolve(undefined),
|
||||
onAccept: () => Promise.resolve(undefined),
|
||||
onSelectPeer: () => undefined,
|
||||
onToggleScreenSharing: () => undefined,
|
||||
onOpenPopout: () => undefined,
|
||||
onClosePopout: () => undefined,
|
||||
streams: {},
|
||||
};
|
||||
|
||||
const MediaCallViewContext = createContext<MediaCallViewContextValue>(defaultMediaCallContextValue);
|
||||
|
||||
export const useMediaCallView = (): MediaCallViewContextValue => useContext(MediaCallViewContext);
|
||||
|
||||
export default MediaCallViewContext;
|
||||
50
packages/ui-voip/src/context/definitions.d.ts
vendored
Normal file
50
packages/ui-voip/src/context/definitions.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { UserStatus } from '@rocket.chat/core-typings';
|
||||
import type { CallFeature } from '@rocket.chat/media-signaling';
|
||||
|
||||
export type InternalPeerInfo = {
|
||||
displayName: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
callerId?: string;
|
||||
status?: UserStatus;
|
||||
};
|
||||
|
||||
export type ExternalPeerInfo = {
|
||||
number: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type ConnectionState = 'CONNECTED' | 'CONNECTING' | 'RECONNECTING';
|
||||
|
||||
export type PeerInfo = InternalPeerInfo | ExternalPeerInfo;
|
||||
|
||||
export type State = 'none' | 'calling' | 'ringing' | 'ongoing';
|
||||
|
||||
interface IBaseSession {
|
||||
state: State;
|
||||
connectionState: ConnectionState;
|
||||
peerInfo: PeerInfo | undefined;
|
||||
transferredBy: string | undefined;
|
||||
muted: boolean;
|
||||
held: boolean;
|
||||
remoteMuted: boolean;
|
||||
remoteHeld: boolean;
|
||||
startedAt?: Date;
|
||||
hidden: boolean;
|
||||
supportedFeatures: readonly CallFeature[];
|
||||
}
|
||||
|
||||
interface IEmptySession extends IBaseSession {
|
||||
state: Extract<State, 'none'>;
|
||||
callId: undefined;
|
||||
}
|
||||
|
||||
interface ICallSession extends IBaseSession {
|
||||
state: Extract<State, 'calling' | 'ringing' | 'ongoing'>;
|
||||
callId: string;
|
||||
peerInfo: PeerInfo;
|
||||
}
|
||||
|
||||
export type SessionState = IEmptySession | ICallSession;
|
||||
10
packages/ui-voip/src/context/index.ts
Normal file
10
packages/ui-voip/src/context/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type * from './definitions';
|
||||
export { MediaCallInstanceContext, useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
export { useMediaCallView } from './MediaCallViewContext';
|
||||
export { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete';
|
||||
export { useWidgetExternalControls } from './useWidgetExternalControls';
|
||||
export { usePeekMediaSessionState } from './usePeekMediaSessionState';
|
||||
export { usePeekMediaSessionCallId } from './usePeekMediaSessionCallId';
|
||||
export { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo';
|
||||
export { usePeekMediaSessionFeatures } from './usePeekMediaSessionFeatures';
|
||||
export type { PeekMediaSessionStateReturn } from './usePeekMediaSessionState';
|
||||
25
packages/ui-voip/src/context/usePeekMediaSessionCallId.ts
Normal file
25
packages/ui-voip/src/context/usePeekMediaSessionCallId.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
|
||||
export type PeekMediaSessionCallIdReturn = string | undefined;
|
||||
|
||||
export const usePeekMediaSessionCallId = (): PeekMediaSessionCallIdReturn => {
|
||||
const { instance } = useMediaCallInstance();
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void): (() => void) => {
|
||||
if (!instance) {
|
||||
return () => undefined;
|
||||
}
|
||||
return instance?.on('sessionStateChange', onStoreChange);
|
||||
},
|
||||
[instance],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
return instance?.getState()?.tempCallId || undefined;
|
||||
}, [instance]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
51
packages/ui-voip/src/context/usePeekMediaSessionFeatures.ts
Normal file
51
packages/ui-voip/src/context/usePeekMediaSessionFeatures.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { CallFeature } from '@rocket.chat/media-signaling';
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
|
||||
export type PeekMediaSessionFeaturesReturn = readonly CallFeature[];
|
||||
|
||||
const areEqual = (a: PeekMediaSessionFeaturesReturn, b: PeekMediaSessionFeaturesReturn) => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return a.every((feature) => b.includes(feature));
|
||||
};
|
||||
|
||||
const emptyFeatures: PeekMediaSessionFeaturesReturn = [];
|
||||
|
||||
export const usePeekMediaSessionFeatures = (): PeekMediaSessionFeaturesReturn => {
|
||||
const { instance } = useMediaCallInstance();
|
||||
const cache = useRef<PeekMediaSessionFeaturesReturn>([]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void): (() => void) => {
|
||||
if (!instance) {
|
||||
return () => undefined;
|
||||
}
|
||||
return instance?.on('sessionStateChange', onStoreChange);
|
||||
},
|
||||
[instance],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!instance) {
|
||||
return emptyFeatures;
|
||||
}
|
||||
|
||||
const instanceState = instance.getState();
|
||||
if (!instanceState?.confirmed) {
|
||||
return emptyFeatures;
|
||||
}
|
||||
|
||||
const { features } = instanceState;
|
||||
|
||||
if (!cache.current || !areEqual(features, cache.current)) {
|
||||
cache.current = features;
|
||||
return features;
|
||||
}
|
||||
return cache.current;
|
||||
}, [instance]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { CallContact } from '@rocket.chat/media-signaling';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MediaCallInstanceContext, defaultContextValue } from './MediaCallInstanceContext';
|
||||
import { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo';
|
||||
|
||||
type MockInstance = {
|
||||
getState: () => { confirmed: true; remoteParticipant: { contact: CallContact } } | null;
|
||||
on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void;
|
||||
};
|
||||
|
||||
const createWrapper = (instance: MockInstance | undefined) => {
|
||||
const wrapper = ({ children }: { children?: ReactNode }) => (
|
||||
<MediaCallInstanceContext.Provider
|
||||
value={{
|
||||
...defaultContextValue,
|
||||
instance: instance as any,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaCallInstanceContext.Provider>
|
||||
);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('usePeekMediaSessionPeerInfo', () => {
|
||||
it('returns undefined when instance is undefined', () => {
|
||||
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
|
||||
wrapper: createWrapper(undefined),
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when instance has no main call', () => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => null,
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when main call has a contact', () => {
|
||||
it('returns external peer info for SIP contact', () => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({
|
||||
confirmed: true,
|
||||
remoteParticipant: {
|
||||
contact: {
|
||||
type: 'sip',
|
||||
id: '+5511999999999',
|
||||
},
|
||||
},
|
||||
}),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ number: '+5511999999999' });
|
||||
});
|
||||
|
||||
it('returns internal peer info for user contact', () => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({
|
||||
confirmed: true,
|
||||
remoteParticipant: {
|
||||
contact: {
|
||||
type: 'user',
|
||||
id: 'userId123',
|
||||
displayName: 'John Doe',
|
||||
username: 'johndoe',
|
||||
sipExtension: '1001',
|
||||
},
|
||||
},
|
||||
}),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
displayName: 'John Doe',
|
||||
userId: 'userId123',
|
||||
username: 'johndoe',
|
||||
callerId: '1001',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionStateChange subscription', () => {
|
||||
it('updates peer info when sessionStateChange is emitted', () => {
|
||||
const emitter = new Emitter<{ sessionStateChange: void }>();
|
||||
|
||||
const defaultInstanceState = {
|
||||
confirmed: true as const,
|
||||
remoteParticipant: {
|
||||
contact: {
|
||||
type: 'sip' as const,
|
||||
id: '+5511999999999',
|
||||
} as CallContact,
|
||||
},
|
||||
};
|
||||
|
||||
let instanceState: typeof defaultInstanceState | null = defaultInstanceState;
|
||||
|
||||
const instance: MockInstance = {
|
||||
getState: () => instanceState,
|
||||
on: (event, onStoreChange) => emitter.on(event, onStoreChange),
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionPeerInfo(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ number: '+5511999999999' });
|
||||
|
||||
act(() => {
|
||||
instanceState = null;
|
||||
emitter.emit('sessionStateChange');
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
instanceState = {
|
||||
confirmed: true,
|
||||
remoteParticipant: {
|
||||
contact: {
|
||||
type: 'user',
|
||||
id: 'userId456',
|
||||
displayName: 'Jane Smith',
|
||||
},
|
||||
},
|
||||
};
|
||||
emitter.emit('sessionStateChange');
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
displayName: 'Jane Smith',
|
||||
userId: 'userId456',
|
||||
username: undefined,
|
||||
callerId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts
Normal file
46
packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
import type { PeerInfo } from './definitions';
|
||||
import { derivePeerInfoFromInstanceState } from '../utils/derivePeerInfoFromInstanceState';
|
||||
|
||||
const areEqual = (a: PeerInfo, b: PeerInfo) => {
|
||||
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(a).every((key) => a[key as keyof PeerInfo] === b[key as keyof PeerInfo]);
|
||||
};
|
||||
|
||||
export const usePeekMediaSessionPeerInfo = (): PeerInfo | undefined => {
|
||||
const { instance } = useMediaCallInstance();
|
||||
const cache = useRef<PeerInfo | undefined>(undefined);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void): (() => void) => {
|
||||
if (!instance) {
|
||||
return () => undefined;
|
||||
}
|
||||
return instance?.on('sessionStateChange', onStoreChange);
|
||||
},
|
||||
[instance],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!instance) {
|
||||
return undefined;
|
||||
}
|
||||
const instanceState = instance.getState();
|
||||
if (!instanceState) {
|
||||
return undefined;
|
||||
}
|
||||
const peerInfo = derivePeerInfoFromInstanceState(instanceState);
|
||||
|
||||
if (!cache.current || !areEqual(peerInfo, cache.current)) {
|
||||
cache.current = peerInfo;
|
||||
return peerInfo;
|
||||
}
|
||||
return cache.current;
|
||||
}, [instance]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
138
packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx
Normal file
138
packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { CallRole, CallState } from '@rocket.chat/media-signaling';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { usePeekMediaSessionState } from './usePeekMediaSessionState';
|
||||
import MockedInstanceProvider from '../providers/MockedInstanceProvider';
|
||||
|
||||
type MockInstance = {
|
||||
getState: () => { state: CallState; localParticipant: { role: CallRole } } | null;
|
||||
on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void;
|
||||
};
|
||||
|
||||
const createWrapper = (instance: MockInstance | undefined) => {
|
||||
const wrapper = ({ children }: { children?: ReactNode }) => (
|
||||
<MockedInstanceProvider instance={instance as any}>{children}</MockedInstanceProvider>
|
||||
);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('usePeekMediaSessionState', () => {
|
||||
it('returns "unavailable" when instance is undefined', () => {
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(undefined),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('returns "available" when instance has no main call', () => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => null,
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('available');
|
||||
});
|
||||
|
||||
it('returns "available" when main call state does not map to a widget state (e.g. hangup)', () => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({ state: 'hangup', localParticipant: { role: 'caller' } }),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('available');
|
||||
});
|
||||
|
||||
describe('when main call maps to a widget state', () => {
|
||||
it.each([
|
||||
['active', 'caller', 'ongoing'] as const,
|
||||
['active', 'callee', 'ongoing'] as const,
|
||||
['renegotiating', 'callee', 'ongoing'] as const,
|
||||
['renegotiating', 'caller', 'ongoing'] as const,
|
||||
])('returns "ongoing" for state "%s" and role "%s"', (callState, role, expected) => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({ state: callState, localParticipant: { role } }),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['ringing', 'none', 'accepted'] as const)('returns "ringing" for callee when state is "%s"', (state) => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({ state, localParticipant: { role: 'callee' } }),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('ringing');
|
||||
});
|
||||
|
||||
it.each(['ringing', 'none', 'accepted'] as const)('returns "calling" for caller when state is "%s"', (state) => {
|
||||
const instance: MockInstance = {
|
||||
getState: () => ({ state, localParticipant: { role: 'caller' } }),
|
||||
on: () => () => undefined,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('calling');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionStateChange subscription', () => {
|
||||
it('updates state when sessionStateChange is emitted', () => {
|
||||
const emitter = new Emitter<{ sessionStateChange: void }>();
|
||||
let instanceState: { state: CallState; localParticipant: { role: CallRole } } | null = {
|
||||
state: 'active',
|
||||
localParticipant: {
|
||||
role: 'caller',
|
||||
},
|
||||
};
|
||||
|
||||
const instance: MockInstance = {
|
||||
getState: () => instanceState,
|
||||
on: (event, onStoreChange) => emitter.on(event, onStoreChange),
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePeekMediaSessionState(), {
|
||||
wrapper: createWrapper(instance),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('ongoing');
|
||||
|
||||
act(() => {
|
||||
instanceState = null;
|
||||
emitter.emit('sessionStateChange');
|
||||
});
|
||||
|
||||
expect(result.current).toBe('available');
|
||||
|
||||
act(() => {
|
||||
instanceState = { state: 'ringing', localParticipant: { role: 'callee' } };
|
||||
emitter.emit('sessionStateChange');
|
||||
});
|
||||
|
||||
expect(result.current).toBe('ringing');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
packages/ui-voip/src/context/usePeekMediaSessionState.ts
Normal file
44
packages/ui-voip/src/context/usePeekMediaSessionState.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState';
|
||||
|
||||
export type PeekMediaSessionStateReturn = 'unavailable' | 'available' | 'ongoing' | 'ringing' | 'calling';
|
||||
|
||||
export const usePeekMediaSessionState = (): PeekMediaSessionStateReturn => {
|
||||
const { instance } = useMediaCallInstance();
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void): (() => void) => {
|
||||
if (!instance) {
|
||||
return () => undefined;
|
||||
}
|
||||
return instance?.on('sessionStateChange', onStoreChange);
|
||||
},
|
||||
[instance],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!instance) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
const instanceState = instance.getState();
|
||||
if (!instanceState) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
const {
|
||||
state: callState,
|
||||
localParticipant: { role },
|
||||
} = instanceState;
|
||||
const state = deriveWidgetStateFromCallState(callState, role);
|
||||
if (!state) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
return state;
|
||||
}, [instance]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
};
|
||||
319
packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Normal file
319
packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
|
||||
import type { PeerInfo } from './definitions';
|
||||
import type { PeerAutocompleteOptions } from '../components';
|
||||
import { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete';
|
||||
import MockedInstanceProvider from '../providers/MockedInstanceProvider';
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useUserPresence: jest.fn(() => ({ _id: 'user1', status: 'online' as const })),
|
||||
}));
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockGetAutocompleteOptions = jest.fn();
|
||||
const mockOnSelectPeer = jest.fn();
|
||||
|
||||
const appRoot = () =>
|
||||
mockAppRoot()
|
||||
.wrap((children) => <MockedInstanceProvider getAutocompleteOptions={mockGetAutocompleteOptions}>{children}</MockedInstanceProvider>)
|
||||
.build();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isFirstPeerAutocompleteOption', () => {
|
||||
it('should return true for values starting with prefix', () => {
|
||||
expect(isFirstPeerAutocompleteOption('rcx-first-option-test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for values not starting with prefix', () => {
|
||||
expect(isFirstPeerAutocompleteOption('test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook', () => {
|
||||
it('should initialize with empty filter and no value', () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
expect(result.current.filter).toBe('');
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update filter when onChangeFilter is called', async () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeFilter('test');
|
||||
});
|
||||
|
||||
expect(result.current.filter).toBe('test');
|
||||
});
|
||||
|
||||
it('should fetch autocomplete options', async () => {
|
||||
const mockOptions: PeerAutocompleteOptions[] = [
|
||||
{ value: 'user1', label: 'User 1', avatarUrl: '' },
|
||||
{ value: 'user2', label: 'User 2', avatarUrl: '' },
|
||||
];
|
||||
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.options).toEqual(mockOptions);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add first option when filter is not empty', async () => {
|
||||
const mockOptions: PeerAutocompleteOptions[] = [{ value: 'user1', label: 'User 1', avatarUrl: '' }];
|
||||
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeFilter('123');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.options).toHaveLength(2);
|
||||
expect(result.current.options[0]).toEqual({
|
||||
value: 'rcx-first-option-123',
|
||||
label: '123',
|
||||
avatarUrl: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return value when peerInfo has userId', () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' };
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
expect(result.current.value).toBe('user1');
|
||||
});
|
||||
|
||||
it('should return undefined value when peerInfo has no userId', () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { number: '123456' };
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('onChangeValue', () => {
|
||||
it('should do nothing if value is an array', async () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeValue(['value1', 'value2']);
|
||||
});
|
||||
|
||||
expect(mockOnSelectPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onSelectPeer with number when value is first option', async () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeValue('rcx-first-option-123456');
|
||||
});
|
||||
|
||||
expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '123456' });
|
||||
});
|
||||
|
||||
it('should call onSelectPeer with full peer info when value matches option', async () => {
|
||||
const mockOptions: PeerAutocompleteOptions[] = [
|
||||
{ value: 'user1', label: 'User 1', avatarUrl: 'avatar.jpg', status: UserStatus.ONLINE },
|
||||
];
|
||||
mockGetAutocompleteOptions.mockResolvedValue(mockOptions);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.options).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onChangeValue('user1');
|
||||
});
|
||||
|
||||
expect(mockOnSelectPeer).toHaveBeenCalledWith({
|
||||
userId: 'user1',
|
||||
displayName: 'User 1',
|
||||
avatarUrl: 'avatar.jpg',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when value does not match any option', async () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.options).toEqual([]);
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.onChangeValue('unknown-user');
|
||||
});
|
||||
}).toThrow('Peer info not found for value: unknown-user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onKeypadPress', () => {
|
||||
it('should append key to filter', async () => {
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onKeypadPress('1');
|
||||
});
|
||||
|
||||
expect(result.current.filter).toBe('1');
|
||||
|
||||
act(() => {
|
||||
result.current.onKeypadPress('2');
|
||||
});
|
||||
|
||||
expect(result.current.filter).toBe('12');
|
||||
|
||||
act(() => {
|
||||
result.current.onKeypadPress('3');
|
||||
});
|
||||
|
||||
expect(result.current.filter).toBe('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user presence updates', () => {
|
||||
it('should update peer status when user presence changes', async () => {
|
||||
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
|
||||
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
|
||||
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
|
||||
|
||||
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.AWAY, statusText: '' });
|
||||
|
||||
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectPeer).toHaveBeenCalledWith({
|
||||
...peerInfo,
|
||||
status: 'away',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update peer status when status has not changed', async () => {
|
||||
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
|
||||
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
|
||||
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
|
||||
|
||||
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
|
||||
|
||||
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update when peerInfo is undefined', async () => {
|
||||
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
|
||||
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
|
||||
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
|
||||
|
||||
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update when peerInfo has no status property', async () => {
|
||||
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
|
||||
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
|
||||
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { number: '123456' };
|
||||
|
||||
mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });
|
||||
|
||||
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update when useUserPresence returns no status', async () => {
|
||||
const { useUserPresence } = await import('@rocket.chat/ui-contexts');
|
||||
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;
|
||||
|
||||
mockGetAutocompleteOptions.mockResolvedValue([]);
|
||||
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
|
||||
|
||||
mockUseUserPresence.mockReturnValue(undefined);
|
||||
|
||||
renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
|
||||
wrapper: appRoot(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelectPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/ui-voip/src/context/usePeerAutocomplete.ts
Normal file
89
packages/ui-voip/src/context/usePeerAutocomplete.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { useUserPresence } from '@rocket.chat/ui-contexts';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PeerInfo } from './definitions';
|
||||
import type { PeerAutocompleteOptions } from '../components';
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
import { mediaCallQueryKeys } from '../utils/queryKeys';
|
||||
|
||||
const PREFIX_FIRST_OPTION = 'rcx-first-option-';
|
||||
|
||||
export const isFirstPeerAutocompleteOption = (value: string) => {
|
||||
return value.startsWith(PREFIX_FIRST_OPTION);
|
||||
};
|
||||
|
||||
const getFirstOption = (filter: string): PeerAutocompleteOptions => {
|
||||
return { value: `${PREFIX_FIRST_OPTION}${filter}`, label: filter, avatarUrl: '' };
|
||||
};
|
||||
|
||||
export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, peerInfo: PeerInfo | undefined) => {
|
||||
const { getAutocompleteOptions } = useMediaCallInstance();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const debouncedFilter = useDebouncedValue(filter, 400);
|
||||
|
||||
const { data: options } = useQuery({
|
||||
queryKey: mediaCallQueryKeys.peerAutocomplete(debouncedFilter),
|
||||
queryFn: async () => {
|
||||
const options = await getAutocompleteOptions(debouncedFilter);
|
||||
|
||||
if (debouncedFilter.length > 0) {
|
||||
return [getFirstOption(debouncedFilter), ...options];
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!peerInfo || !('status' in peerInfo) || !status?.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === peerInfo?.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectPeer({
|
||||
...peerInfo,
|
||||
status: status.status,
|
||||
});
|
||||
}, [status, peerInfo, onSelectPeer]);
|
||||
|
||||
return {
|
||||
options,
|
||||
onChangeFilter: setFilter,
|
||||
onChangeValue: (value: string | string[]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirstPeerAutocompleteOption(value)) {
|
||||
onSelectPeer({ number: value.replace(PREFIX_FIRST_OPTION, '') });
|
||||
return;
|
||||
}
|
||||
|
||||
const localInfo = options.find((option) => option.value === value);
|
||||
|
||||
if (!localInfo) {
|
||||
throw new Error(`Peer info not found for value: ${value}`);
|
||||
}
|
||||
|
||||
onSelectPeer({
|
||||
userId: localInfo.value,
|
||||
displayName: localInfo.label,
|
||||
avatarUrl: localInfo.avatarUrl,
|
||||
status: localInfo.status,
|
||||
});
|
||||
},
|
||||
value: peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined,
|
||||
filter,
|
||||
onKeypadPress: (key: string) => setFilter((filter) => filter + key),
|
||||
};
|
||||
};
|
||||
18
packages/ui-voip/src/context/useRegisterView.ts
Normal file
18
packages/ui-voip/src/context/useRegisterView.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from '.';
|
||||
import type { AvailableViews } from './MediaCallInstanceContext';
|
||||
|
||||
const useRegisterView = (view: AvailableViews) => {
|
||||
const { currentViews, registerView, unregisterView } = useMediaCallInstance();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerView(view);
|
||||
|
||||
return () => unregisterView(view);
|
||||
}, [view, registerView, unregisterView]);
|
||||
|
||||
return currentViews;
|
||||
};
|
||||
|
||||
export default useRegisterView;
|
||||
24
packages/ui-voip/src/context/useWidgetExternalControls.ts
Normal file
24
packages/ui-voip/src/context/useWidgetExternalControls.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useMediaCallInstance } from './MediaCallInstanceContext';
|
||||
import type { PeerInfo } from './definitions';
|
||||
import { getEndCall } from '../utils/instanceControlsGetters';
|
||||
|
||||
export const useWidgetExternalControls = () => {
|
||||
const { instance, openWidget, closeWidget, targetWidgetVisibility } = useMediaCallInstance();
|
||||
|
||||
const toggleWidget = useCallback(
|
||||
(peerInfo?: PeerInfo) => {
|
||||
if (targetWidgetVisibility === 'closed') {
|
||||
openWidget(peerInfo);
|
||||
return;
|
||||
}
|
||||
closeWidget();
|
||||
},
|
||||
[closeWidget, openWidget, targetWidgetVisibility],
|
||||
);
|
||||
|
||||
const endCall = useCallback(() => getEndCall(instance)(), [instance]);
|
||||
|
||||
return { toggleWidget, endCall };
|
||||
};
|
||||
5
packages/ui-voip/src/definitions/IceServer.ts
Normal file
5
packages/ui-voip/src/definitions/IceServer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type IceServer = {
|
||||
urls: string;
|
||||
username?: string;
|
||||
credential?: string;
|
||||
};
|
||||
30
packages/ui-voip/src/definitions/callHistoryContacts.ts
Normal file
30
packages/ui-voip/src/definitions/callHistoryContacts.ts
Normal 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);
|
||||
};
|
||||
2
packages/ui-voip/src/definitions/index.ts
Normal file
2
packages/ui-voip/src/definitions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './callHistoryContacts';
|
||||
export type * from './IceServer';
|
||||
136
packages/ui-voip/src/generate-landing-view.tsx
Normal file
136
packages/ui-voip/src/generate-landing-view.tsx
Normal 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
7
packages/ui-voip/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
RocketChatDesktop?: IRocketChatDesktop;
|
||||
}
|
||||
}
|
||||
586
packages/ui-voip/src/hooks/VoipPopupDraggable/DraggableCore.ts
Normal file
586
packages/ui-voip/src/hooks/VoipPopupDraggable/DraggableCore.ts
Normal 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];
|
||||
};
|
||||
@@ -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('');
|
||||
});
|
||||
},
|
||||
};
|
||||
4
packages/ui-voip/src/hooks/index.ts
Normal file
4
packages/ui-voip/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './useDevicePermissionPrompt';
|
||||
export { useDraggable } from './VoipPopupDraggable/DraggableCore';
|
||||
export { useMediaCallAction } from './useMediaCallAction';
|
||||
export { useMediaCallOpenRoomTracker } from './useMediaCallOpenRoomTracker';
|
||||
206
packages/ui-voip/src/hooks/useDevicePermissionPrompt.spec.tsx
Normal file
206
packages/ui-voip/src/hooks/useDevicePermissionPrompt.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
166
packages/ui-voip/src/hooks/useDevicePermissionPrompt.tsx
Normal file
166
packages/ui-voip/src/hooks/useDevicePermissionPrompt.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
16
packages/ui-voip/src/hooks/useIceServers.ts
Normal file
16
packages/ui-voip/src/hooks/useIceServers.ts
Normal 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]);
|
||||
};
|
||||
244
packages/ui-voip/src/hooks/useMediaCallAction.spec.ts
Normal file
244
packages/ui-voip/src/hooks/useMediaCallAction.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
66
packages/ui-voip/src/hooks/useMediaCallAction.ts
Normal file
66
packages/ui-voip/src/hooks/useMediaCallAction.ts
Normal 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]);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
14
packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.ts
Normal file
14
packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.ts
Normal 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]);
|
||||
};
|
||||
124
packages/ui-voip/src/hooks/useTonePlayer.ts
Normal file
124
packages/ui-voip/src/hooks/useTonePlayer.ts
Normal 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;
|
||||
};
|
||||
20
packages/ui-voip/src/index.ts
Normal file
20
packages/ui-voip/src/index.ts
Normal 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';
|
||||
75
packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx
Normal file
75
packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAudioStream } from './useAudioStream';
|
||||
import useAvailableViewTracker from './useAvailableViewTracker';
|
||||
import { useGetAutocompleteOptions } from './useGetAutocompleteOptions';
|
||||
import { useInstanceState } from './useInstanceState';
|
||||
import { useMediaSessionInstance } from './useMediaSessionInstance';
|
||||
import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext';
|
||||
|
||||
export type MediaCallInstanceProviderProps = {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstanceProviderProps) => {
|
||||
const { currentViews, registerView, unregisterView } = useAvailableViewTracker();
|
||||
const user = useUser();
|
||||
const instance = useMediaSessionInstance(user?._id, enabled);
|
||||
|
||||
const { openWidget, closeWidget, targetPeer, setTargetPeer, targetWidgetVisibility, openRoomId, setOpenRoomId } =
|
||||
useInstanceState(instance);
|
||||
|
||||
const [remoteStreamRefCallback, audioElement] = useAudioStream(instance);
|
||||
|
||||
const getAutocompleteOptions = useGetAutocompleteOptions(instance);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
instance,
|
||||
audioElement,
|
||||
openRoomId,
|
||||
setOpenRoomId,
|
||||
getAutocompleteOptions,
|
||||
currentViews,
|
||||
registerView,
|
||||
unregisterView,
|
||||
openWidget,
|
||||
closeWidget,
|
||||
setTargetPeer,
|
||||
targetPeer,
|
||||
targetWidgetVisibility,
|
||||
}),
|
||||
[
|
||||
instance,
|
||||
audioElement,
|
||||
openRoomId,
|
||||
setOpenRoomId,
|
||||
getAutocompleteOptions,
|
||||
currentViews,
|
||||
registerView,
|
||||
unregisterView,
|
||||
openWidget,
|
||||
closeWidget,
|
||||
setTargetPeer,
|
||||
targetPeer,
|
||||
targetWidgetVisibility,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<MediaCallInstanceContext.Provider value={value}>
|
||||
{createPortal(
|
||||
<audio ref={remoteStreamRefCallback}>
|
||||
<track kind='captions' />
|
||||
</audio>,
|
||||
document.body,
|
||||
)}
|
||||
{children}
|
||||
</MediaCallInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCallInstanceProvider;
|
||||
26
packages/ui-voip/src/providers/MediaCallLogger.ts
Normal file
26
packages/ui-voip/src/providers/MediaCallLogger.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { IMediaSignalLogger } from '@rocket.chat/media-signaling';
|
||||
|
||||
export class MediaCallLogger implements IMediaSignalLogger {
|
||||
private isDebug: boolean;
|
||||
|
||||
constructor() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
this.isDebug = Boolean(searchParams.get('debug') || searchParams.get('debug-voip'));
|
||||
}
|
||||
|
||||
log(...what: any[]): void {
|
||||
this.isDebug && console.log(...what);
|
||||
}
|
||||
|
||||
debug(...what: any[]): void {
|
||||
this.isDebug && console.debug(...what);
|
||||
}
|
||||
|
||||
error(...what: any[]): void {
|
||||
console.error(...what);
|
||||
}
|
||||
|
||||
warn(...what: any[]): void {
|
||||
console.warn(...what);
|
||||
}
|
||||
}
|
||||
32
packages/ui-voip/src/providers/MediaCallProvider.tsx
Normal file
32
packages/ui-voip/src/providers/MediaCallProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AnchorPortal } from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import MediaCallInstanceProvider from './MediaCallInstanceProvider';
|
||||
import MediaCallViewProvider from './MediaCallViewProvider';
|
||||
import { MediaCallWidget } from '../views';
|
||||
import MediaCallPopout from '../views/MediaCallPopout';
|
||||
|
||||
export type MediaCallProviderProps = {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const MediaCallProvider = ({ children, enabled = true }: MediaCallProviderProps) => {
|
||||
return (
|
||||
<MediaCallInstanceProvider enabled={enabled}>
|
||||
{enabled && (
|
||||
<>
|
||||
<MediaCallViewProvider>
|
||||
<AnchorPortal id='rcx-media-call-widget-portal'>
|
||||
<MediaCallWidget />
|
||||
</AnchorPortal>
|
||||
</MediaCallViewProvider>
|
||||
<MediaCallPopout />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MediaCallInstanceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCallProvider;
|
||||
249
packages/ui-voip/src/providers/MediaCallViewProvider.tsx
Normal file
249
packages/ui-voip/src/providers/MediaCallViewProvider.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useGoToDirectMessage } from '@rocket.chat/ui-client';
|
||||
import type { Device } from '@rocket.chat/ui-contexts';
|
||||
import {
|
||||
useSetOutputMediaDevice,
|
||||
useSetInputMediaDevice,
|
||||
useSetModal,
|
||||
useSelectedDevices,
|
||||
useToastMessageDispatch,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCallSounds } from './useCallSounds';
|
||||
import { useDesktopNotifications } from './useDesktopNotifications';
|
||||
import { useMediaSession } from './useMediaSession';
|
||||
import { useMediaSessionControls } from './useMediaSessionControls';
|
||||
import { useScreenShareStreams } from './useScreenShareStreams';
|
||||
import useWidgetPositionTracker from './useWidgetPositionTracker';
|
||||
import { useMediaCallInstance } from '../context/MediaCallInstanceContext';
|
||||
import MediaCallViewContext from '../context/MediaCallViewContext';
|
||||
import type { PeerInfo } from '../context/definitions';
|
||||
import { stopTracks, useDevicePermissionPrompt2 } from '../hooks/useDevicePermissionPrompt';
|
||||
import { isValidTone, useTonePlayer } from '../hooks/useTonePlayer';
|
||||
import TransferModal from '../views/TransferModal';
|
||||
|
||||
export type MediaCallViewProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const setModal = useSetModal();
|
||||
|
||||
const { instance, audioElement, openRoomId, registerView, unregisterView, setTargetPeer, targetPeer } = useMediaCallInstance();
|
||||
|
||||
const sessionState = useMediaSession(instance);
|
||||
const controls = useMediaSessionControls(instance);
|
||||
|
||||
useDesktopNotifications(sessionState);
|
||||
|
||||
const setOutputMediaDevice = useSetOutputMediaDevice();
|
||||
const setInputMediaDevice = useSetInputMediaDevice();
|
||||
|
||||
const { audioInput, audioOutput } = useSelectedDevices() || {};
|
||||
|
||||
const requestDevice = useDevicePermissionPrompt2();
|
||||
|
||||
const onClickDirectMessage = useGoToDirectMessage(
|
||||
{ username: sessionState.peerInfo && 'username' in sessionState.peerInfo ? sessionState.peerInfo.username : undefined },
|
||||
openRoomId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioInput?.id && !sessionState.hidden) {
|
||||
void controls.changeDevice(audioInput.id).catch((e) => console.error('Media Call - failed to change input device', e));
|
||||
}
|
||||
}, [audioInput?.id, controls, sessionState.hidden]);
|
||||
|
||||
useCallSounds(
|
||||
sessionState.hidden ? 'none' : sessionState.state,
|
||||
useCallback(
|
||||
(callback) => {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
return instance.on('droppedCall', () => {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
[instance],
|
||||
),
|
||||
);
|
||||
|
||||
const onMute = () => controls.toggleMute();
|
||||
const onHold = () => controls.toggleHold();
|
||||
|
||||
const onCall = async () => {
|
||||
if (sessionState.state !== 'none') {
|
||||
console.error('Cannot start call in state', sessionState.state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetPeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startCall = (micless: boolean) => {
|
||||
if ('userId' in targetPeer) {
|
||||
void controls.startCall(targetPeer.userId, 'user', micless);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('number' in targetPeer) {
|
||||
void controls.startCall(targetPeer.number, 'sip', micless);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('MediaCall - New call - something went wrong when trying to call. PeerInfo is missing userId and/or number.');
|
||||
};
|
||||
|
||||
try {
|
||||
const stream = await requestDevice({ actionType: 'outgoing' });
|
||||
stopTracks(stream);
|
||||
startCall(false);
|
||||
} catch (error) {
|
||||
console.error('Media Call - Error requesting device', error);
|
||||
startCall(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onAccept = async () => {
|
||||
if (sessionState.state !== 'ringing') {
|
||||
console.error('Cannot accept call in state', sessionState.state);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await requestDevice({ actionType: 'incoming' });
|
||||
stopTracks(stream);
|
||||
controls.acceptCall(false);
|
||||
} catch (error) {
|
||||
console.error('MediaCall - onAccept - Failed to get device, procceeding without microphone', error);
|
||||
controls.acceptCall(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeviceChange = (device: Device) => {
|
||||
const parameters = {
|
||||
actionType: 'device-change',
|
||||
constraints: {
|
||||
audio: {
|
||||
deviceId: {
|
||||
exact: device.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (device.type === 'audiooutput') {
|
||||
if (!audioElement?.current) return;
|
||||
setOutputMediaDevice({ outputDevice: device, HTMLAudioElement: audioElement.current });
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.type === 'audioinput') {
|
||||
void requestDevice(parameters)
|
||||
.then(async (stream) => {
|
||||
stopTracks(stream);
|
||||
setInputMediaDevice(device);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error changing device', error);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Invalid device type', device.type);
|
||||
};
|
||||
|
||||
const onForward = () => {
|
||||
const offCallback = instance?.once('endedCall', () => {
|
||||
setModal(null);
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
offCallback?.();
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const onConfirm = (kind: 'user' | 'sip', peer: { displayName: string; id: string }) => {
|
||||
offCallback?.();
|
||||
controls.forwardCall(kind, peer.id);
|
||||
setModal(null);
|
||||
dispatchToastMessage({ type: 'success', message: t('Call_transfered_to__name__', { name: peer.displayName }) });
|
||||
};
|
||||
|
||||
setModal(<TransferModal onCancel={onCancel} onConfirm={onConfirm} />);
|
||||
};
|
||||
|
||||
const playTone = useTonePlayer(audioOutput?.id);
|
||||
|
||||
const onTone = (tone: string) => {
|
||||
controls.sendTone(tone);
|
||||
if (isValidTone(tone)) {
|
||||
playTone(tone);
|
||||
}
|
||||
};
|
||||
|
||||
const onEndCall = () => {
|
||||
controls.endCall();
|
||||
};
|
||||
|
||||
const onSelectPeer = (peerInfo: PeerInfo) => {
|
||||
setTargetPeer(peerInfo);
|
||||
};
|
||||
|
||||
const onToggleScreenSharing = () => {
|
||||
controls.toggleScreenSharing();
|
||||
};
|
||||
|
||||
const onOpenPopout = useCallback(() => {
|
||||
registerView('popout');
|
||||
}, [registerView]);
|
||||
|
||||
const onClosePopout = useCallback(() => {
|
||||
unregisterView('popout');
|
||||
}, [unregisterView]);
|
||||
|
||||
const streams = useScreenShareStreams(instance);
|
||||
|
||||
const { onChangePosition, lastKnownPosition } = useWidgetPositionTracker();
|
||||
|
||||
useEffect(() => {
|
||||
return instance?.on('endedCall', () => {
|
||||
onChangePosition(null);
|
||||
});
|
||||
}, [instance, onChangePosition]);
|
||||
|
||||
const contextValue = {
|
||||
sessionState,
|
||||
targetPeer,
|
||||
onClickDirectMessage,
|
||||
onMute,
|
||||
onHold,
|
||||
onDeviceChange,
|
||||
onForward,
|
||||
onTone,
|
||||
onEndCall,
|
||||
onCall,
|
||||
onAccept,
|
||||
onSelectPeer,
|
||||
onToggleScreenSharing,
|
||||
onOpenPopout,
|
||||
onClosePopout,
|
||||
streams,
|
||||
widgetPositionTracker: {
|
||||
onChangePosition,
|
||||
lastKnownPosition,
|
||||
},
|
||||
};
|
||||
|
||||
return <MediaCallViewContext.Provider value={contextValue}>{children}</MediaCallViewContext.Provider>;
|
||||
};
|
||||
|
||||
export default MediaCallViewProvider;
|
||||
68
packages/ui-voip/src/providers/MockedInstanceProvider.tsx
Normal file
68
packages/ui-voip/src/providers/MockedInstanceProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { MediaSignalingSession } from '@rocket.chat/media-signaling';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import useAvailableViewTracker from './useAvailableViewTracker';
|
||||
import { useInstanceState } from './useInstanceState';
|
||||
import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext';
|
||||
import type { MediaCallInstanceContextValue } from '../context/MediaCallInstanceContext';
|
||||
import { defaultSessionState } from '../context/MediaCallViewContext';
|
||||
import type { SessionState } from '../context/definitions';
|
||||
|
||||
export const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`;
|
||||
|
||||
export const mockedPeers = Array.from({ length: 100 }, (_, i) => ({
|
||||
value: `user-${i}`,
|
||||
label: `User ${i}`,
|
||||
identifier: `000${i}`,
|
||||
avatarUrl,
|
||||
}));
|
||||
|
||||
export type MockedInstanceProviderProps = {
|
||||
children: ReactNode;
|
||||
sessionState?: SessionState;
|
||||
} & Partial<MediaCallInstanceContextValue>;
|
||||
|
||||
const MockedInstanceProvider = ({ children, sessionState = defaultSessionState, ...props }: MockedInstanceProviderProps) => {
|
||||
const { currentViews, registerView, unregisterView } = useAvailableViewTracker();
|
||||
const [instance] = useState(
|
||||
() =>
|
||||
({
|
||||
getState: () => null,
|
||||
on: () => () => undefined,
|
||||
once: () => () => undefined,
|
||||
}) as unknown as MediaSignalingSession,
|
||||
);
|
||||
|
||||
const sessionStateRef = useRef(sessionState);
|
||||
sessionStateRef.current = sessionState;
|
||||
|
||||
const { openWidget, closeWidget, targetPeer, setTargetPeer, targetWidgetVisibility, openRoomId, setOpenRoomId } =
|
||||
useInstanceState(instance);
|
||||
|
||||
const getAutocompleteOptions = useCallback(
|
||||
(filter: string) => Promise.resolve(mockedPeers.filter((peer) => peer.label.toLowerCase().includes(filter.toLowerCase()))),
|
||||
[],
|
||||
);
|
||||
|
||||
const value = {
|
||||
instance,
|
||||
audioElement: undefined,
|
||||
openRoomId,
|
||||
setOpenRoomId,
|
||||
currentViews,
|
||||
registerView,
|
||||
unregisterView,
|
||||
targetWidgetVisibility,
|
||||
targetPeer,
|
||||
setTargetPeer,
|
||||
getAutocompleteOptions,
|
||||
openWidget,
|
||||
closeWidget,
|
||||
...props,
|
||||
};
|
||||
|
||||
return <MediaCallInstanceContext.Provider value={value}>{children}</MediaCallInstanceContext.Provider>;
|
||||
};
|
||||
|
||||
export default MockedInstanceProvider;
|
||||
158
packages/ui-voip/src/providers/MockedMediaCallProvider.tsx
Normal file
158
packages/ui-voip/src/providers/MockedMediaCallProvider.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import type { CallFeature } from '@rocket.chat/media-signaling';
|
||||
import { callFeatureList } from '@rocket.chat/media-signaling';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { MockedInstanceProviderProps } from './MockedInstanceProvider';
|
||||
import MockedInstanceProvider, { avatarUrl, mockedPeers } from './MockedInstanceProvider';
|
||||
import MediaCallViewContext from '../context/MediaCallViewContext';
|
||||
import type { State, PeerInfo, SessionState, ConnectionState } from '../context/definitions';
|
||||
|
||||
export type MockedMediaCallProviderProps = {
|
||||
children: ReactNode;
|
||||
state?: State;
|
||||
connectionState?: ConnectionState;
|
||||
transferredBy?: string;
|
||||
remoteMuted?: boolean;
|
||||
remoteHeld?: boolean;
|
||||
muted?: boolean;
|
||||
held?: boolean;
|
||||
onClickDirectMessage?: () => void;
|
||||
instanceProps?: Partial<MockedInstanceProviderProps>;
|
||||
supportedFeatures?: readonly CallFeature[];
|
||||
};
|
||||
|
||||
const MockedMediaCallProvider = ({
|
||||
children,
|
||||
state = 'none',
|
||||
connectionState = 'CONNECTED',
|
||||
onClickDirectMessage = undefined,
|
||||
transferredBy = undefined,
|
||||
remoteMuted = false,
|
||||
remoteHeld = false,
|
||||
muted = false,
|
||||
held = false,
|
||||
instanceProps,
|
||||
supportedFeatures = callFeatureList,
|
||||
}: MockedMediaCallProviderProps) => {
|
||||
const [peerInfo, setPeerInfo] = useState<PeerInfo | undefined>({
|
||||
displayName: 'John Doe',
|
||||
userId: '1234567890',
|
||||
avatarUrl,
|
||||
username: 'john.doe',
|
||||
callerId: '1234567890',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
const [widgetState, setWidgetState] = useState<State>(state);
|
||||
const [mutedState, setMutedState] = useState(muted);
|
||||
const [heldState, setHeldState] = useState(held);
|
||||
|
||||
const onMute = () => setMutedState((prev) => !prev);
|
||||
const onHold = () => setHeldState((prev) => !prev);
|
||||
|
||||
const clearState = () => {
|
||||
setMutedState(false);
|
||||
setHeldState(false);
|
||||
};
|
||||
|
||||
const onDeviceChange = (device: any) => {
|
||||
console.log('device', device);
|
||||
};
|
||||
|
||||
const onForward = () => {
|
||||
console.log('forward');
|
||||
clearState();
|
||||
setWidgetState('none');
|
||||
};
|
||||
|
||||
const onTone = (tone: string) => {
|
||||
console.log('tone', tone);
|
||||
};
|
||||
|
||||
const onEndCall = () => {
|
||||
clearState();
|
||||
setWidgetState('none');
|
||||
};
|
||||
|
||||
const getPeerInfo = (id: string) => {
|
||||
const peer = mockedPeers.find((item) => item.value === id);
|
||||
if (!peer) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
displayName: peer.label,
|
||||
userId: peer.value,
|
||||
avatarUrl: peer.avatarUrl,
|
||||
username: peer.identifier,
|
||||
callerId: peer.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onCall = async (id?: string) => {
|
||||
if (id) {
|
||||
setPeerInfo(await getPeerInfo(id));
|
||||
}
|
||||
|
||||
switch (widgetState) {
|
||||
case 'ringing':
|
||||
setWidgetState('ongoing');
|
||||
break;
|
||||
case 'none':
|
||||
setWidgetState('calling');
|
||||
setTimeout(() => {
|
||||
setWidgetState('ongoing');
|
||||
}, 1000);
|
||||
break;
|
||||
case 'calling':
|
||||
setWidgetState('none');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectPeer = (peerInfo: PeerInfo) => {
|
||||
setPeerInfo(peerInfo);
|
||||
};
|
||||
|
||||
const sessionState = {
|
||||
state: widgetState,
|
||||
connectionState,
|
||||
peerInfo,
|
||||
transferredBy,
|
||||
hidden: false,
|
||||
muted: mutedState,
|
||||
held: heldState,
|
||||
remoteMuted,
|
||||
remoteHeld,
|
||||
callId: undefined,
|
||||
supportedFeatures,
|
||||
} as SessionState;
|
||||
|
||||
const contextValue = {
|
||||
sessionState,
|
||||
targetPeer: peerInfo,
|
||||
onClickDirectMessage,
|
||||
onMute,
|
||||
onHold,
|
||||
onDeviceChange,
|
||||
onForward,
|
||||
onTone,
|
||||
onEndCall,
|
||||
onCall,
|
||||
onAccept: onCall,
|
||||
onSelectPeer,
|
||||
streams: {},
|
||||
onToggleScreenSharing: () => undefined,
|
||||
onOpenPopout: () => undefined,
|
||||
onClosePopout: () => undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<MockedInstanceProvider sessionState={sessionState} {...instanceProps}>
|
||||
<MediaCallViewContext.Provider value={contextValue}>{children}</MediaCallViewContext.Provider>
|
||||
</MockedInstanceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MockedMediaCallProvider;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user