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';
|
||||
Reference in New Issue
Block a user