Ongoing call tiles - Implement the required views (#34197)

* Add a common view snapshot type

This will contain the common snapshot values used by both dm ongoing
call tile view and room ongoing call tile view.

* Implement the common join button component

* Implement the call icon component

* Implement the common container component

* Support single digit minutes in existing clock component

* Implement the view for duration component

* Add static profile pics to be used in storybook tests

* Implement view for member avatar component

* Implement view for face pile component

* Implement RoomOngoingCallTileView

* Implement DmOngoingCallTileView

* Support the new tiles in RootTileView

* Export all the views

* Update all the screenshots

* Add a comment about the 10px margin

* Add comments to explain css heights and widths
This commit is contained in:
R Midhun Suresh
2026-07-13 13:20:50 +00:00
committed by GitHub
parent 5396e18db3
commit 14e4650c72
51 changed files with 2152 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -27,3 +27,10 @@ export const LotOfSeconds: Story = {
seconds: 99999999999999,
},
};
export const SingleDigitMinutes: Story = {
args: {
seconds: 100,
minutesMaxLength: 1,
},
};
@@ -12,7 +12,7 @@ import { describe, it, expect } from "vitest";
import * as stories from "./Clock.stories.tsx";
const { Default, LotOfSeconds } = composeStories(stories);
const { Default, LotOfSeconds, SingleDigitMinutes } = composeStories(stories);
describe("Clock", () => {
it("renders the clock", () => {
@@ -24,4 +24,9 @@ describe("Clock", () => {
const { container } = render(<LotOfSeconds />);
expect(container).toMatchSnapshot();
});
it("renders the clock with single digit minute", () => {
const { container } = render(<SingleDigitMinutes />);
expect(container).toMatchSnapshot();
});
});
@@ -16,6 +16,22 @@ export interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "r
* The number of seconds to display.
*/
seconds: number;
/**
* The number of positions to pad the minutes part.
*
* @example
* If minutesMaxLength = 1, the clock will show 5:31 instead of 05:31.
*/
minutesMaxLength?: number;
/**
* The number of positions to pad the hour part.
*
* @example
* If hoursMaxLength = 1, the clock will show 1:05:31 instead of 01:05:31.
*/
hoursMaxLength?: number;
}
/**
@@ -28,7 +44,7 @@ export interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "r
* <Clock seconds={125} />
* ```
*/
export function Clock({ seconds, className, ...rest }: Props): JSX.Element {
export function Clock({ seconds, className, minutesMaxLength, hoursMaxLength, ...rest }: Props): JSX.Element {
// Memoize current second to avoid recalculating the duration when seconds changes slightly (e.g. 1.2 -> 1.3)
const currentSecond = useMemo(() => Math.floor(seconds), [seconds]);
const duration = useMemo(() => calculateDuration(currentSecond), [currentSecond]);
@@ -40,7 +56,10 @@ export function Clock({ seconds, className, ...rest }: Props): JSX.Element {
className={classNames("mx_Clock", className)}
{...rest}
>
{formatSeconds(seconds)}
{formatSeconds(seconds, {
minutesMaxLength,
hoursMaxLength,
})}
</time>
);
}
@@ -21,3 +21,14 @@ exports[`Clock > renders the clock with a lot of seconds 1`] = `
</time>
</div>
`;
exports[`Clock > renders the clock with single digit minute 1`] = `
<div>
<time
class="mx_Clock"
datetime="PT1M40S"
>
1:40
</time>
</div>
`;
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FacePileViewSnapshot, FacePileView } from "./FacePileView";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import profileLink1 from "../../../static/profile-pics/profile1.png";
import profileLink2 from "../../../static/profile-pics/profile2.png";
import profileLink3 from "../../../static/profile-pics/profile3.png";
import { MockViewModel, useMockedViewModel } from "../viewmodel";
import { type MemberAvatarViewSnapshot } from "../MemberAvatar/MemberAvatarView";
const FacePileViewWrapperImpl = (snapshot: FacePileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(snapshot, {});
return <FacePileView vm={vm} />;
};
const FacePileViewWrapper = withViewDocs(FacePileViewWrapperImpl, FacePileView);
type MockProp = { id: string; name: string; url?: string };
export class MockMemberAvatarViewModel extends MockViewModel<MemberAvatarViewSnapshot> {
public constructor({ id, name, url }: MockProp) {
super({
id,
name,
size: "20px",
title: id,
url,
});
}
}
const meta = {
title: "Core/FacePileView",
component: FacePileViewWrapper,
tags: ["autodocs"],
args: {
memberAvatarViewModels: [
new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }),
new MockMemberAvatarViewModel({ id: "@riley:m.org", name: "Riley", url: profileLink2 }),
new MockMemberAvatarViewModel({ id: "@andi:m.org", name: "Andi", url: profileLink3 }),
],
},
} satisfies Meta<typeof FacePileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { AvatarStack } from "@vector-im/compound-web";
import { type MemberAvatarViewModel, MemberAvatarView } from "../MemberAvatar/MemberAvatarView";
import { useViewModel, type ViewModel } from "../viewmodel";
export interface FacePileViewSnapshot {
/**
* The sub vms for the member avatars.
*/
memberAvatarViewModels: MemberAvatarViewModel[];
}
export type FacePileViewModel = ViewModel<FacePileViewSnapshot>;
export interface FacePileViewProps {
vm: FacePileViewModel;
/**
* Additional class names for this component.
*/
classNames?: string;
}
/**
* View that renders a face pile view.
*/
export function FacePileView(props: FacePileViewProps): React.ReactNode {
const { memberAvatarViewModels } = useViewModel(props.vm);
if (memberAvatarViewModels.length === 0) return null;
return (
<AvatarStack className={props.classNames}>
{memberAvatarViewModels.map((vm) => (
<MemberAvatarView vm={vm} key={vm.getSnapshot().id} />
))}
</AvatarStack>
);
}
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type MemberAvatarViewSnapshot, MemberAvatarView } from "./MemberAvatarView";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import profile from "../../../static/profile-pics/profile3.png";
import { useMockedViewModel } from "../viewmodel";
const MemberAvatarViewWrapperImpl = (snapshot: MemberAvatarViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(snapshot, {});
return <MemberAvatarView vm={vm} />;
};
const MemberAvatarViewWrapper = withViewDocs(MemberAvatarViewWrapperImpl, MemberAvatarView);
const meta = {
title: "Core/MemberAvatarView",
component: MemberAvatarViewWrapper,
tags: ["autodocs"],
argTypes: {
url: {
control: "text",
},
},
args: {
id: "@bob:m.org",
name: "Bob",
size: "20px",
title: "@bob:m.org",
url: profile,
},
} satisfies Meta<typeof MemberAvatarViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Avatar } from "@vector-im/compound-web";
import { useViewModel, type ViewModel } from "../viewmodel";
export interface MemberAvatarViewSnapshot {
/**
* The display name of this member.
*/
name: string;
/**
* The mxid of this member.
*/
id: string;
/**
* The avatar url.
*/
url?: string;
/**
* Size of the avatar.
*/
size: string;
/**
* Title passed to the avatar container (button or span).
*/
title?: string;
}
export type MemberAvatarViewModel = ViewModel<MemberAvatarViewSnapshot>;
interface MemberAvatarViewProps {
vm: MemberAvatarViewModel;
/**
* Additional class names for this component.
*/
classNames?: string;
}
/**
* View for rendering the avatar for a given member.
*/
export function MemberAvatarView(props: MemberAvatarViewProps): React.ReactNode {
const { name, id, url, size } = useViewModel(props.vm);
return <Avatar className={props.classNames} name={name} id={id} src={url} size={size} />;
}
@@ -9,16 +9,21 @@
* Formats a number of seconds into a human-readable string.
* @param inSeconds
*/
export function formatSeconds(inSeconds: number): string {
export function formatSeconds(
inSeconds: number,
opts?: { hoursMaxLength?: number; minutesMaxLength?: number },
): string {
const isNegative = inSeconds < 0;
inSeconds = Math.abs(inSeconds);
const hours = Math.floor(inSeconds / (60 * 60))
.toFixed(0)
.padStart(2, "0");
.padStart(opts?.hoursMaxLength ?? 2, "0");
const minutes = Math.floor((inSeconds % (60 * 60)) / 60)
.toFixed(0)
.padStart(2, "0");
.padStart(opts?.minutesMaxLength ?? 2, "0");
const seconds = Math.floor((inSeconds % (60 * 60)) % 60)
.toFixed(0)
.padStart(2, "0");
@@ -233,6 +233,20 @@
"call_declined": "Call declined",
"call_declined_by_us": "You declined a call"
},
"ongoing": {
"common": {
"call_started_by": "%(startedByDisplayName)s started a call",
"join_button": "Join"
},
"dm": {
"call_started": "Call started",
"title": "Call in progress"
},
"room": {
"join_count": "%(joinedCount)s joined",
"title": "Group call in progress"
}
},
"tombstone": {
"room": {
"title": "Group call ended"
+2
View File
@@ -11,6 +11,8 @@ export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./core/AvatarWithDetails";
export * from "./core/MemberAvatar/MemberAvatarView.tsx";
export * from "./core/FacePile/FacePileView.tsx";
export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
@@ -8,6 +8,8 @@
import React from "react";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import { RoomOngoingCallTileView, type RoomCallStartedTileViewModel } from "./ongoing/room/RoomOngoingCallTileView";
import { DmOngoingCallTileView, type DmOngoingCallTileViewModel } from "./ongoing/dm/DmOngoingCallTileView";
import {
RoomTombstoneCallTileView,
type RoomTombstoneCallTileViewModel,
@@ -18,6 +20,8 @@ import { DmTombstoneCallTileView, type DmTombstoneCallTileViewModel } from "./to
* Map from tile type to view model.
*/
interface TileTypeToViewModelMap {
"ongoing-call-room": RoomCallStartedTileViewModel;
"ongoing-call-dm": DmOngoingCallTileViewModel;
"tombstone-call-room": RoomTombstoneCallTileViewModel;
"tombstone-call-dm": DmTombstoneCallTileViewModel;
}
@@ -39,6 +43,10 @@ interface Props {
export function RootCallTileView({ vm }: Props): React.ReactNode {
const { tileType, tileViewModel } = useViewModel(vm);
switch (tileType) {
case "ongoing-call-room":
return <RoomOngoingCallTileView vm={tileViewModel as TileTypeToViewModelMap["ongoing-call-room"]} />;
case "ongoing-call-dm":
return <DmOngoingCallTileView vm={tileViewModel as TileTypeToViewModelMap["ongoing-call-dm"]} />;
case "tombstone-call-room":
return <RoomTombstoneCallTileView vm={tileViewModel as TileTypeToViewModelMap["tombstone-call-room"]} />;
case "tombstone-call-dm":
@@ -7,5 +7,9 @@
export * from "./tombstone/room/RoomTombstoneCallTileView";
export * from "./tombstone/dm/DmTombstoneCallTileView";
export type * from "./ongoing/common";
export * from "./ongoing/room/RoomOngoingCallTileView";
export * from "./ongoing/components/Duration/DurationView";
export * from "./ongoing/dm/DmOngoingCallTileView";
export * from "./RootCallTileView";
export * from "./common";
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type FacePileViewSnapshot } from "../../../../../core/FacePile/FacePileView";
import { type MemberAvatarViewSnapshot } from "../../../../../core/MemberAvatar/MemberAvatarView";
import { MockViewModel } from "../../../../../core/viewmodel";
type MockProp = { id: string; name: string; url?: string };
export class MockMemberAvatarViewModel extends MockViewModel<MemberAvatarViewSnapshot> {
public constructor({ id, name, url }: MockProp) {
super({
id,
name,
size: "20px",
title: id,
url,
});
}
}
export class MockFacePileViewModel extends MockViewModel<FacePileViewSnapshot> {
public constructor(items: MockProp[]) {
const memberAvatarViewModels = items.slice(0, 3).map((item) => new MockMemberAvatarViewModel(item));
super({ memberAvatarViewModels });
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.title {
font: var(--cpd-font-body-md-semibold);
color: var(--cpd-color-text-primary);
/* This height is from the figma design */
min-height: 23px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.content {
flex: 1;
overflow: hidden;
/* Width specified to allow the children to overflow and render with ellipsis */
min-width: 47px;
}
.avatar {
min-width: 20px;
}
.facepile {
min-width: fit-content;
}
@@ -0,0 +1,67 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type FacePileViewModel } from "../../../../../core/FacePile/FacePileView";
import { type MemberAvatarViewModel } from "../../../../../core/MemberAvatar/MemberAvatarView";
import { type ViewModel } from "../../../../../core/viewmodel";
import { type CallDirection } from "../common";
import { type DurationViewModel } from "./components/Duration/DurationView";
/**
* This snapshot type contains state used by both RoomOngoingCallTileView and
* DmOngoingCallTileView.
*/
export interface CommonOngoingCallTileViewSnapshot {
/**
* The display name of whoever started this call.
*/
startedByDisplayName: string;
/**
* Vm for rendering the duration of this call.
*/
durationViewModel?: DurationViewModel;
/**
* Avatar vm for the user who started this call.
*/
memberAvatarViewModel: MemberAvatarViewModel;
/**
* Face pile view-model for the participants on this call.
*/
facePileViewModel: FacePileViewModel;
/**
* Whether this is an incoming or outgoing call.
*/
callDirection: CallDirection;
/**
* Whether our user has joined this call.
*/
isJoined: boolean;
/**
* Whether our user can join this call or not.
*/
isJoinable: boolean;
/**
* Whether this call has participants other than who started the call.
*/
callHasOtherParticipants: boolean;
}
export interface CommonOngoingCallTileViewAction {
/**
* Join this call
*/
join: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
export type CallStartedTileFooViewModel = ViewModel<CommonOngoingCallTileViewSnapshot>;
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.container {
min-width: 36px;
height: 36px;
border-radius: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-subtle-secondary);
}
.icon {
color: var(--cpd-color-icon-primary);
}
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { CallType } from "../../../common";
import { Flex } from "../../../../../../../core/utils/Flex";
import styles from "./CallIcon.module.css";
import { Icon } from "../Icon/Icon";
interface Props {
/**
* The type of call.
*/
callType: CallType;
}
/**
* Common call icon component that is used by ongoing call tiles.
*/
export function CallIcon(props: Props): React.ReactNode {
return (
<Flex align="center" justify="center" className={styles.container}>
<Icon classNames={styles.icon} callType={props.callType} height={20} width={20} />
</Flex>
);
}
@@ -0,0 +1,12 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.container {
font: var(--cpd-font-body-xs-regular);
color: var(--cpd-color-text-secondary);
min-width: 30px;
}
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import styles from "./DurationView.module.css";
import { useViewModel, type ViewModel } from "../../../../../../../core/viewmodel";
import { Clock } from "../../../../../../../audio/Clock";
export interface DurationViewSnapshot {
/**
* The number of seconds that this call has been ongoing for.
*/
duration: number;
}
export type DurationViewModel = ViewModel<DurationViewSnapshot>;
interface Props {
vm: DurationViewModel;
/**
* Additional class names for this component.
*/
classNames?: string;
}
/**
* View to show the duration of the call.
*/
export function DurationView(props: Props): React.ReactNode {
const { duration } = useViewModel(props.vm);
const classes = classNames(styles.container, props.classNames);
return (
<div className={classes}>
(<Clock seconds={duration} minutesMaxLength={1} className={classes} />)
</div>
);
}
@@ -0,0 +1,42 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentType, type SVGAttributes } from "react";
import { VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { CallType } from "../../../common";
interface Props extends SVGAttributes<SVGGElement> {
/**
* The type of this call i.e voice or video.
*/
callType: CallType;
/**
* The extra class names to add to the icon.
*/
classNames?: string;
/**
* Height of the icon
*/
height: number;
/**
* Width of the icon
*/
width: number;
}
/**
* Component that renders the correct svg icon based on call type.
*/
export const Icon: ComponentType<Props> = ({ callType, classNames, height, width, ...rest }: Props) => {
switch (callType) {
case CallType.Video:
return <VideoCallSolidIcon className={classNames} width={width} height={height} {...rest} />;
case CallType.Voice:
return <VoiceCallSolidIcon className={classNames} width={width} height={height} {...rest} />;
}
};
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { Button } from "@vector-im/compound-web";
import React, { type ComponentProps, type SVGAttributes } from "react";
import { Icon } from "../Icon/Icon";
import type { CallType } from "../../../common";
import { useI18n } from "../../../../../../../core/i18n/i18nContext";
interface Props extends ComponentProps<typeof Button> {
join: (event: React.MouseEvent<HTMLButtonElement>) => void;
callType: CallType;
}
function withIcon(callType: CallType) {
return function ButtonIcon(props: SVGAttributes<SVGGElement>) {
return <Icon callType={callType} {...props} height={20} width={20} />;
};
}
/**
* Join button used by all ongoing call tiles.
*/
export function JoinButton({ join, callType, ...rest }: Props): React.ReactNode {
const { translate: _t } = useI18n();
return (
<Button onClick={join} Icon={withIcon(callType)} size="md" {...rest}>
{_t("timeline|call_tile|ongoing|common|join_button")}
</Button>
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.container {
min-height: 70px;
padding: var(--cpd-space-3x);
border: solid 1px var(--cpd-color-border-interactive-primary);
border-radius: var(--cpd-space-2x);
box-sizing: border-box;
width: 100%;
/* All call tiles have a 10px margin at the top and bottom */
margin: 10px 0;
}
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import styles from "./TileContainer.module.css";
import { Flex } from "../../../../../../../core/utils/Flex";
/**
* Common container inside which all ongoing call tiles render.
*/
export function TileContainer({ children }: React.PropsWithChildren): React.ReactNode {
return (
<Flex direction="row" align="center" className={styles.container}>
{children}
</Flex>
);
}
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type DmOngoingCallTileViewSnapshot, DmOngoingCallTileView } from "./DmOngoingCallTileView";
import { MockViewModel, useMockedViewModel } from "../../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../../.storybook/withViewDocs";
import { type CommonOngoingCallTileViewAction } from "../common";
import { CallDirection, CallType } from "../../common";
import profileLink1 from "../../../../../../../static/profile-pics/profile1.png";
import profileLink2 from "../../../../../../../static/profile-pics/profile2.png";
import { MockFacePileViewModel, MockMemberAvatarViewModel } from "../call-mocks";
const DmOngoingCallTileViewWrapperImpl = ({
join,
...rest
}: DmOngoingCallTileViewSnapshot & CommonOngoingCallTileViewAction): React.ReactNode => {
const vm = useMockedViewModel(rest, { join });
return <DmOngoingCallTileView vm={vm} />;
};
const DmOngoingCallTileViewWrapper = withViewDocs(DmOngoingCallTileViewWrapperImpl, DmOngoingCallTileView);
const meta = {
title: "Timeline/Timeline Event/Call/Ongoing/DmOngoingCallTileView",
component: DmOngoingCallTileViewWrapper,
tags: ["autodocs"],
argTypes: {
callDirection: {
options: [CallDirection.Incoming, CallDirection.Outgoing],
control: { type: "radio" },
},
callType: {
options: [CallType.Voice, CallType.Video],
control: { type: "radio" },
},
},
args: {
callDirection: CallDirection.Incoming,
startedByDisplayName: "Bob",
durationViewModel: new MockViewModel({ duration: 100 }),
isJoinable: true,
isJoined: false,
join: () => {},
memberAvatarViewModel: new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }),
facePileViewModel: new MockFacePileViewModel([
{ id: "@bob:m.org", name: "Bob", url: profileLink1 },
{ id: "@riley:m.org", name: "Riley", url: profileLink2 },
]),
callType: CallType.Voice,
callHasOtherParticipants: false,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4",
},
visOptions: {
mask: [".duration"],
},
},
} satisfies Meta<typeof DmOngoingCallTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const IncomingVoiceCall: Story = {
args: {
callDirection: CallDirection.Incoming,
callType: CallType.Voice,
},
};
export const IncomingVideoCall: Story = {
args: {
callDirection: CallDirection.Incoming,
callType: CallType.Video,
},
};
export const OutgoingVoiceCall: Story = {
args: {
callDirection: CallDirection.Outgoing,
callType: CallType.Voice,
},
};
export const OutgoingVideoCall: Story = {
args: {
callDirection: CallDirection.Outgoing,
callType: CallType.Video,
},
};
export const VoiceCallInProgress: Story = {
args: {
callType: CallType.Voice,
isJoined: true,
callHasOtherParticipants: true,
},
};
export const VideoCallInProgress: Story = {
args: {
callType: CallType.Video,
isJoined: true,
callHasOtherParticipants: true,
},
};
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { describe, expect, it } from "vitest";
import React from "react";
import { render } from "@test-utils";
import * as Stories from "./DmOngoingCallTileView.stories";
const {
IncomingVideoCall,
IncomingVoiceCall,
OutgoingVideoCall,
OutgoingVoiceCall,
VideoCallInProgress,
VoiceCallInProgress,
} = composeStories(Stories);
describe("DmOngoingCallTileView", () => {
describe("renders the tile", () => {
it("IncomingVideoCall", () => {
const { container } = render(<IncomingVideoCall />);
expect(container).toMatchSnapshot();
});
it("IncomingVoiceCall", () => {
const { container } = render(<IncomingVoiceCall />);
expect(container).toMatchSnapshot();
});
it("OutgoingVideoCall", () => {
const { container } = render(<OutgoingVideoCall />);
expect(container).toMatchSnapshot();
});
it("OutgoingVoiceCall", () => {
const { container } = render(<OutgoingVoiceCall />);
expect(container).toMatchSnapshot();
});
it("VoiceCallInProgress", () => {
const { container } = render(<VoiceCallInProgress />);
expect(container).toMatchSnapshot();
});
it("VideoCallInProgress", () => {
const { container } = render(<VideoCallInProgress />);
expect(container).toMatchSnapshot();
});
});
});
@@ -0,0 +1,97 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel";
import { TileContainer } from "../components/TileContainer/TileContainer";
import { CallIcon } from "../components/CallIcon/CallIcon";
import { CallDirection, type CallType } from "../../common";
import { Flex } from "../../../../../../core/utils/Flex";
import commonStyles from "../common.module.css";
import { JoinButton } from "../components/JoinButton/JoinButton";
import { DurationView } from "../components/Duration/DurationView";
import { MemberAvatarView } from "../../../../../../core/MemberAvatar/MemberAvatarView";
import { type CommonOngoingCallTileViewSnapshot, type CommonOngoingCallTileViewAction } from "../common";
import { FacePileView } from "../../../../../../core/FacePile/FacePileView";
import { useI18n } from "../../../../../../core/i18n/i18nContext";
export interface DmOngoingCallTileViewSnapshot extends CommonOngoingCallTileViewSnapshot {
callType: CallType;
}
export type DmOngoingCallTileViewModel = ViewModel<DmOngoingCallTileViewSnapshot> & CommonOngoingCallTileViewAction;
interface Props {
vm: DmOngoingCallTileViewModel;
}
/**
* View that renders the tile content for an ongoing call in a DM.
*/
export function DmOngoingCallTileView(props: Props): React.ReactNode {
const snapshot = useViewModel(props.vm);
const { callType, callDirection, isJoinable, isJoined, durationViewModel } = snapshot;
let content: React.ReactNode;
if (snapshot.callHasOtherParticipants) {
content = <CallJoinedContent snapshot={snapshot} />;
} else if (callDirection === CallDirection.Incoming) {
content = <IncomingCallContent snapshot={snapshot} />;
} else {
content = <OutgoingCallContent snapshot={snapshot} />;
}
return (
<TileContainer>
<Flex align="center" gap="var(--cpd-space-3x)" className={commonStyles.content}>
<CallIcon callType={callType} />
<Flex gap="6px" align="center" className={commonStyles.content}>
{content}
</Flex>
<Flex align="center" gap="var(--cpd-space-3x)" wrap="wrap-reverse">
{durationViewModel && <DurationView classNames="duration" vm={durationViewModel} />}
{!isJoined && (
<JoinButton disabled={!isJoinable} callType={callType} join={(ev) => props.vm.join(ev)} />
)}
</Flex>
</Flex>
</TileContainer>
);
}
function IncomingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode {
const { translate: _t } = useI18n();
return (
<>
<MemberAvatarView classNames={commonStyles.avatar} vm={snapshot.memberAvatarViewModel} />
<div className={commonStyles.title}>
{_t("timeline|call_tile|ongoing|common|call_started_by", {
startedByDisplayName: snapshot.startedByDisplayName,
})}
</div>
</>
);
}
function OutgoingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode {
const { translate: _t } = useI18n();
return (
<>
<MemberAvatarView classNames={commonStyles.avatar} vm={snapshot.memberAvatarViewModel} />
<div className={commonStyles.title}>{_t("timeline|call_tile|ongoing|dm|call_started")}</div>
</>
);
}
function CallJoinedContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode {
const { translate: _t } = useI18n();
return (
<>
<FacePileView classNames={commonStyles.facepile} vm={snapshot.facePileViewModel} />
<div className={commonStyles.title}>{_t("timeline|call_tile|ongoing|dm|title")}</div>
</>
);
}
@@ -0,0 +1,603 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DmOngoingCallTileView > renders the tile > IncomingVideoCall 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8 common-module_avatar"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<div
class="common-module_title"
>
Bob started a call
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
exports[`DmOngoingCallTileView > renders the tile > IncomingVoiceCall 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8 common-module_avatar"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<div
class="common-module_title"
>
Bob started a call
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
exports[`DmOngoingCallTileView > renders the tile > OutgoingVideoCall 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8 common-module_avatar"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<div
class="common-module_title"
>
Call started
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
exports[`DmOngoingCallTileView > renders the tile > OutgoingVoiceCall 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8 common-module_avatar"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<div
class="common-module_title"
>
Call started
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
exports[`DmOngoingCallTileView > renders the tile > VideoCallInProgress 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<div
class="_stacked-avatars_va14e_109 common-module_facepile"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<span
aria-label="@riley:m.org"
class="_avatar_va14e_8"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile2.png"
width="20px"
/>
</span>
</div>
<div
class="common-module_title"
>
Call in progress
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
</div>
</div>
</div>
</div>
`;
exports[`DmOngoingCallTileView > renders the tile > VoiceCallInProgress 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<div
class="_stacked-avatars_va14e_109 common-module_facepile"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<span
aria-label="@riley:m.org"
class="_avatar_va14e_8"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile2.png"
width="20px"
/>
</span>
</div>
<div
class="common-module_title"
>
Call in progress
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.subContainer {
/* This height is from the figma design */
min-height: 23px;
width: 100%;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
}
.startedTextContainer {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,97 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type RoomOngoingCallTileViewSnapshot, RoomOngoingCallTileView } from "./RoomOngoingCallTileView";
import { MockViewModel, useMockedViewModel } from "../../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../../.storybook/withViewDocs";
import { type CommonOngoingCallTileViewAction } from "../common";
import { CallDirection } from "../../common";
import profileLink1 from "../../../../../../../static/profile-pics/profile1.png";
import profileLink2 from "../../../../../../../static/profile-pics/profile2.png";
import profileLink3 from "../../../../../../../static/profile-pics/profile3.png";
import { MockFacePileViewModel, MockMemberAvatarViewModel } from "../call-mocks";
const RoomOngoingCallTileViewWrapperImpl = ({
join,
...rest
}: RoomOngoingCallTileViewSnapshot & CommonOngoingCallTileViewAction): React.ReactNode => {
const vm = useMockedViewModel(rest, { join });
return <RoomOngoingCallTileView vm={vm} />;
};
const RoomOngoingCallTileViewWrapper = withViewDocs(RoomOngoingCallTileViewWrapperImpl, RoomOngoingCallTileView);
const meta = {
title: "Timeline/Timeline Event/Call/Ongoing/RoomOngoingCallTileView",
component: RoomOngoingCallTileViewWrapper,
tags: ["autodocs"],
argTypes: {
callDirection: {
options: [CallDirection.Incoming, CallDirection.Outgoing],
control: { type: "radio" },
},
isCallIgnored: {
control: { type: "boolean" },
},
},
args: {
callDirection: CallDirection.Incoming,
startedByDisplayName: "Bob",
durationViewModel: new MockViewModel({ duration: 100 }),
isCallIgnored: false,
totalParticipants: 8,
callHasOtherParticipants: true,
isJoinable: true,
isJoined: false,
join: () => {},
memberAvatarViewModel: new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }),
facePileViewModel: new MockFacePileViewModel([
{ id: "@bob:m.org", name: "Bob", url: profileLink1 },
{ id: "@riley:m.org", name: "Riley", url: profileLink2 },
{ id: "@andi:m.org", name: "Andi", url: profileLink3 },
{ id: "@foo1:m.org", name: "Foo 1" },
{ id: "@foo2:m.org", name: "Foo 2" },
{ id: "@foo3:m.org", name: "Foo 3" },
{ id: "@foo4:m.org", name: "Foo 4" },
{ id: "@foo5:m.org", name: "Foo 5" },
]),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4",
},
},
} satisfies Meta<typeof RoomOngoingCallTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const RoomCallWithoutOtherParticipants: Story = {
args: {
callHasOtherParticipants: false,
totalParticipants: 1,
},
};
export const RoomCallIgnored: Story = {
args: {
isCallIgnored: true,
},
};
export const RoomCallJoined: Story = {
args: {
isJoined: true,
},
};
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { describe, expect, it } from "vitest";
import React from "react";
import { render } from "@test-utils";
import * as Stories from "./RoomOngoingCallTileView.stories";
const { RoomCallIgnored, RoomCallJoined, RoomCallWithoutOtherParticipants } = composeStories(Stories);
describe("RoomOngoingCallTileView", () => {
describe("renders the tile", () => {
it("RoomCallIgnored", () => {
const { container } = render(<RoomCallIgnored />);
expect(container).toMatchSnapshot();
});
it("RoomCallJoined", () => {
const { container } = render(<RoomCallJoined />);
expect(container).toMatchSnapshot();
});
it("RoomCallWithoutOtherParticipants", () => {
const { container } = render(<RoomCallWithoutOtherParticipants />);
expect(container).toMatchSnapshot();
});
});
});
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel";
import { TileContainer } from "../components/TileContainer/TileContainer";
import { CallIcon } from "../components/CallIcon/CallIcon";
import { CallType } from "../../common";
import { Flex } from "../../../../../../core/utils/Flex";
import commonStyles from "../common.module.css";
import styles from "./RoomOngoingCallTileView.module.css";
import { JoinButton } from "../components/JoinButton/JoinButton";
import { DurationView } from "../components/Duration/DurationView";
import { MemberAvatarView } from "../../../../../../core/MemberAvatar/MemberAvatarView";
import { FacePileView } from "../../../../../../core/FacePile/FacePileView";
import type { CommonOngoingCallTileViewAction, CommonOngoingCallTileViewSnapshot } from "../common";
import { useI18n } from "../../../../../../core/i18n/i18nContext";
export interface RoomOngoingCallTileViewSnapshot extends CommonOngoingCallTileViewSnapshot {
/**
* The total number of participants in this call.
*/
totalParticipants: number;
/**
* Whether the user ignored this call.
*/
isCallIgnored?: boolean;
}
export type RoomCallStartedTileViewModel = ViewModel<RoomOngoingCallTileViewSnapshot> & CommonOngoingCallTileViewAction;
interface Props {
vm: RoomCallStartedTileViewModel;
}
/**
* View that renders the tile content for an ongoing call in a room.
*/
export function RoomOngoingCallTileView(props: Props): React.ReactNode {
const snapshot = useViewModel(props.vm);
const { isJoinable, isJoined, isCallIgnored, callHasOtherParticipants, durationViewModel } = snapshot;
const { translate: _t } = useI18n();
return (
<TileContainer>
<Flex align="center" gap="var(--cpd-space-3x)" className={commonStyles.content}>
<CallIcon callType={CallType.Video} />
<Flex direction="column" className={commonStyles.content}>
<div className={commonStyles.title}>{_t("timeline|call_tile|ongoing|room|title")}</div>
{callHasOtherParticipants || isCallIgnored ? (
<CallJoinedOrIgnoredContent snapshot={snapshot} />
) : (
<CallStartedContent snapshot={snapshot} />
)}
</Flex>
<Flex align="center" gap="var(--cpd-space-3x)" wrap="wrap-reverse">
{durationViewModel && <DurationView classNames="duration" vm={durationViewModel} />}
{!isJoined && (
<JoinButton
callType={CallType.Video}
disabled={!isJoinable}
join={(event) => {
props.vm.join(event);
}}
/>
)}
</Flex>
</Flex>
</TileContainer>
);
}
/**
* This is the content of the tile when the call has participants other than who started the call or
* the call was ignored (by clicking decline button on the toast).
*/
function CallJoinedOrIgnoredContent({ snapshot }: { snapshot: RoomOngoingCallTileViewSnapshot }): React.ReactNode {
const { facePileViewModel, totalParticipants } = snapshot;
const { translate: _t } = useI18n();
const joinedCount = totalParticipants;
return (
<Flex className={styles.subContainer} gap="6px" align="center">
<FacePileView classNames={commonStyles.facepile} vm={facePileViewModel} />
{_t("timeline|call_tile|ongoing|room|join_count", { joinedCount })}
</Flex>
);
}
/**
* This is the content of the tile when the call has just started.
*/
function CallStartedContent({ snapshot }: { snapshot: RoomOngoingCallTileViewSnapshot }): React.ReactNode {
const { memberAvatarViewModel, startedByDisplayName } = snapshot;
const { translate: _t } = useI18n();
return (
<Flex className={styles.subContainer} gap="6px" align="center">
<MemberAvatarView classNames={commonStyles.avatar} vm={memberAvatarViewModel} />
<div className={styles.startedTextContainer}>
{_t("timeline|call_tile|ongoing|common|call_started_by", { startedByDisplayName })}
</div>
</Flex>
);
}
@@ -0,0 +1,385 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoomOngoingCallTileView > renders the tile > RoomCallIgnored 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="common-module_title"
>
Group call in progress
</div>
<div
class="Flex-module_flex RoomOngoingCallTileView-module_subContainer"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<div
class="_stacked-avatars_va14e_109 common-module_facepile"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<span
aria-label="@riley:m.org"
class="_avatar_va14e_8"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile2.png"
width="20px"
/>
</span>
<span
aria-label="@andi:m.org"
class="_avatar_va14e_8"
data-color="4"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile3.png"
width="20px"
/>
</span>
</div>
8 joined
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
exports[`RoomOngoingCallTileView > renders the tile > RoomCallJoined 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="common-module_title"
>
Group call in progress
</div>
<div
class="Flex-module_flex RoomOngoingCallTileView-module_subContainer"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<div
class="_stacked-avatars_va14e_109 common-module_facepile"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<span
aria-label="@riley:m.org"
class="_avatar_va14e_8"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile2.png"
width="20px"
/>
</span>
<span
aria-label="@andi:m.org"
class="_avatar_va14e_8"
data-color="4"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile3.png"
width="20px"
/>
</span>
</div>
8 joined
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomOngoingCallTileView > renders the tile > RoomCallWithoutOtherParticipants 1`] = `
<div>
<div
class="Flex-module_flex TileContainer-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex CallIcon-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="CallIcon-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
<div
class="Flex-module_flex common-module_content"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="common-module_title"
>
Group call in progress
</div>
<div
class="Flex-module_flex RoomOngoingCallTileView-module_subContainer"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 6px; --mx-flex-wrap: nowrap;"
>
<span
aria-label="@bob:m.org"
class="_avatar_va14e_8 common-module_avatar"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 20px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="20px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/profile-pics/profile1.png"
width="20px"
/>
</span>
<div
class="RoomOngoingCallTileView-module_startedTextContainer"
>
Bob started a call
</div>
</div>
</div>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: wrap-reverse;"
>
<div
class="DurationView-module_container duration"
>
(
<time
class="mx_Clock DurationView-module_container duration"
datetime="PT1M40S"
>
1:40
</time>
)
</div>
<button
aria-disabled="false"
class="_button_1nw83_8 _has-icon_1nw83_60"
data-kind="primary"
data-size="md"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
Join
</button>
</div>
</div>
</div>
</div>
`;
@@ -13,6 +13,7 @@
border-radius: var(--cpd-space-2x);
padding: var(--cpd-space-2x) var(--cpd-space-3x);
box-sizing: border-box;
/* All call tiles have a 10px margin at the top and bottom */
margin: 10px 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB