snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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