diff --git a/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png b/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png new file mode 100644 index 00000000000..b10ca2b05eb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..2aa7004c518 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..b53127dc269 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..0084a30c998 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png new file mode 100644 index 00000000000..2074ef2e2bc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png new file mode 100644 index 00000000000..f64c33841a7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png new file mode 100644 index 00000000000..c25642b2594 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png new file mode 100644 index 00000000000..2a67aaf2018 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png new file mode 100644 index 00000000000..0b68335e8bc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png new file mode 100644 index 00000000000..b1e3558aa40 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..aacf440ce87 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png new file mode 100644 index 00000000000..b83e4dbd4b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png new file mode 100644 index 00000000000..638ec1f9db3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png new file mode 100644 index 00000000000..943216cf831 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png differ diff --git a/packages/shared-components/src/audio/Clock/Clock.stories.tsx b/packages/shared-components/src/audio/Clock/Clock.stories.tsx index df2fab9ffc1..90818e4b65b 100644 --- a/packages/shared-components/src/audio/Clock/Clock.stories.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.stories.tsx @@ -27,3 +27,10 @@ export const LotOfSeconds: Story = { seconds: 99999999999999, }, }; + +export const SingleDigitMinutes: Story = { + args: { + seconds: 100, + minutesMaxLength: 1, + }, +}; diff --git a/packages/shared-components/src/audio/Clock/Clock.test.tsx b/packages/shared-components/src/audio/Clock/Clock.test.tsx index 9350b7cae3b..895f29d3eb7 100644 --- a/packages/shared-components/src/audio/Clock/Clock.test.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.test.tsx @@ -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(); expect(container).toMatchSnapshot(); }); + + it("renders the clock with single digit minute", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); }); diff --git a/packages/shared-components/src/audio/Clock/Clock.tsx b/packages/shared-components/src/audio/Clock/Clock.tsx index 2bbe6e4a014..54b86b85b81 100644 --- a/packages/shared-components/src/audio/Clock/Clock.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.tsx @@ -16,6 +16,22 @@ export interface Props extends Pick, "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, "aria-live" | "r * * ``` */ -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, + })} ); } diff --git a/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap index e400b9dc5b9..ee41a0c8432 100644 --- a/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap +++ b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap @@ -21,3 +21,14 @@ exports[`Clock > renders the clock with a lot of seconds 1`] = ` `; + +exports[`Clock > renders the clock with single digit minute 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx b/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx new file mode 100644 index 00000000000..962bc131b84 --- /dev/null +++ b/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx @@ -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 ; +}; + +const FacePileViewWrapper = withViewDocs(FacePileViewWrapperImpl, FacePileView); + +type MockProp = { id: string; name: string; url?: string }; + +export class MockMemberAvatarViewModel extends MockViewModel { + 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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/core/FacePile/FacePileView.tsx b/packages/shared-components/src/core/FacePile/FacePileView.tsx new file mode 100644 index 00000000000..878f1b320cc --- /dev/null +++ b/packages/shared-components/src/core/FacePile/FacePileView.tsx @@ -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; + +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 ( + + {memberAvatarViewModels.map((vm) => ( + + ))} + + ); +} diff --git a/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx new file mode 100644 index 00000000000..952edbd005d --- /dev/null +++ b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx @@ -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 ; +}; + +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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx new file mode 100644 index 00000000000..b3b541c14c4 --- /dev/null +++ b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx @@ -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; + +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 ; +} diff --git a/packages/shared-components/src/core/utils/DateUtils.ts b/packages/shared-components/src/core/utils/DateUtils.ts index 6df2836d20e..2cbcc9c263e 100644 --- a/packages/shared-components/src/core/utils/DateUtils.ts +++ b/packages/shared-components/src/core/utils/DateUtils.ts @@ -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"); diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 51a7aa1296b..054f43c9354 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -231,6 +231,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" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 41ef3a54fb0..8de6b3b73f4 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx index dadf9ac9c91..0d880f9e207 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx @@ -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 ; + case "ongoing-call-dm": + return ; case "tombstone-call-room": return ; case "tombstone-call-dm": diff --git a/packages/shared-components/src/room/timeline/event-tile/call/index.ts b/packages/shared-components/src/room/timeline/event-tile/call/index.ts index 6a710d4e674..5ed5bb3989f 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/index.ts +++ b/packages/shared-components/src/room/timeline/event-tile/call/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts new file mode 100644 index 00000000000..1b32b6d8e01 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts @@ -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 { + public constructor({ id, name, url }: MockProp) { + super({ + id, + name, + size: "20px", + title: id, + url, + }); + } +} + +export class MockFacePileViewModel extends MockViewModel { + public constructor(items: MockProp[]) { + const memberAvatarViewModels = items.slice(0, 3).map((item) => new MockMemberAvatarViewModel(item)); + super({ memberAvatarViewModels }); + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css new file mode 100644 index 00000000000..bf6d897ccd8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css @@ -0,0 +1,30 @@ +/* + * 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); + min-height: 23px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; +} + +.content { + flex: 1; + overflow: hidden; + min-width: 47px; +} + +.avatar { + min-width: 20px; +} + +.facepile { + min-width: fit-content; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts new file mode 100644 index 00000000000..0b5cbe31495 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts @@ -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) => void; +} + +export type CallStartedTileFooViewModel = ViewModel; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css new file mode 100644 index 00000000000..316ca334074 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css @@ -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); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx new file mode 100644 index 00000000000..fa124e012e7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx @@ -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 ( + + + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css new file mode 100644 index 00000000000..37a84a3f8ce --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css @@ -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; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx new file mode 100644 index 00000000000..b7183346e4f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx @@ -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; + +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 ( +
+ () +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx new file mode 100644 index 00000000000..a5b59213cb8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx @@ -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 { + /** + * 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 = ({ callType, classNames, height, width, ...rest }: Props) => { + switch (callType) { + case CallType.Video: + return ; + case CallType.Voice: + return ; + } +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx new file mode 100644 index 00000000000..05aa637cf6e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx @@ -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 { + join: (event: React.MouseEvent) => void; + callType: CallType; +} + +function withIcon(callType: CallType) { + return function ButtonIcon(props: SVGAttributes) { + return ; + }; +} + +/** + * Join button used by all ongoing call tiles. + */ +export function JoinButton({ join, callType, ...rest }: Props): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css new file mode 100644 index 00000000000..8afd33f2438 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css @@ -0,0 +1,16 @@ +/* + * 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%; + margin: 10px 0; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx new file mode 100644 index 00000000000..3b2a9681009 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx new file mode 100644 index 00000000000..0aee39a93b7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx @@ -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 ; +}; + +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; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx new file mode 100644 index 00000000000..e98e298fef7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("IncomingVoiceCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("OutgoingVideoCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("OutgoingVoiceCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VoiceCallInProgress", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VideoCallInProgress", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx new file mode 100644 index 00000000000..ffb8ddaa015 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx @@ -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 & 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 = ; + } else if (callDirection === CallDirection.Incoming) { + content = ; + } else { + content = ; + } + return ( + + + + + {content} + + + {durationViewModel && } + {!isJoined && ( + props.vm.join(ev)} /> + )} + + + + ); +} + +function IncomingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
+ {_t("timeline|call_tile|ongoing|common|call_started_by", { + startedByDisplayName: snapshot.startedByDisplayName, + })} +
+ + ); +} + +function OutgoingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
{_t("timeline|call_tile|ongoing|dm|call_started")}
+ + ); +} + +function CallJoinedContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
{_t("timeline|call_tile|ongoing|dm|title")}
+ + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap new file mode 100644 index 00000000000..17735a29a72 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap @@ -0,0 +1,603 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DmOngoingCallTileView > renders the tile > IncomingVideoCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Bob started a call +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > IncomingVoiceCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Bob started a call +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > OutgoingVideoCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Call started +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > OutgoingVoiceCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Call started +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > VideoCallInProgress 1`] = ` +
+
+
+
+ + + +
+
+
+ + + + + + +
+
+ Call in progress +
+
+
+
+ ( + + ) +
+
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > VoiceCallInProgress 1`] = ` +
+
+
+
+ + + +
+
+
+ + + + + + +
+
+ Call in progress +
+
+
+
+ ( + + ) +
+
+
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css new file mode 100644 index 00000000000..e0fb3453ad5 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css @@ -0,0 +1,19 @@ +/* + * 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 { + 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; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx new file mode 100644 index 00000000000..83ecbb7ef5f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx @@ -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 ; +}; + +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; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx new file mode 100644 index 00000000000..9359c4c0bc9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("RoomCallJoined", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("RoomCallWithoutOtherParticipants", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx new file mode 100644 index 00000000000..e434a3957a2 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx @@ -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 & 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 ( + + + + +
{_t("timeline|call_tile|ongoing|room|title")}
+ + {callHasOtherParticipants || isCallIgnored ? ( + + ) : ( + + )} +
+ + {durationViewModel && } + {!isJoined && ( + { + props.vm.join(event); + }} + /> + )} + +
+
+ ); +} + +/** + * 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 ( + + + {_t("timeline|call_tile|ongoing|room|join_count", { joinedCount })} + + ); +} + +/** + * 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 ( + + +
+ {_t("timeline|call_tile|ongoing|common|call_started_by", { startedByDisplayName })} +
+
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap new file mode 100644 index 00000000000..e0ac849d585 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap @@ -0,0 +1,385 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallIgnored 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+
+ + + + + + + + + +
+ 8 joined +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallJoined 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+
+ + + + + + + + + +
+ 8 joined +
+
+
+
+ ( + + ) +
+
+
+
+
+`; + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallWithoutOtherParticipants 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+ + + +
+ Bob started a call +
+
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; diff --git a/packages/shared-components/static/profile-pics/profile1.png b/packages/shared-components/static/profile-pics/profile1.png new file mode 100644 index 00000000000..94c52f3f83a Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile1.png differ diff --git a/packages/shared-components/static/profile-pics/profile2.png b/packages/shared-components/static/profile-pics/profile2.png new file mode 100644 index 00000000000..bb43ae7ca26 Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile2.png differ diff --git a/packages/shared-components/static/profile-pics/profile3.png b/packages/shared-components/static/profile-pics/profile3.png new file mode 100644 index 00000000000..95c06110084 Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile3.png differ