Implement tombstone tiles for calls in room and DM (#34141)

* Implement tombstone tile view for a room

* Implement tombstone tile view for a DM

* Implement root call tile view

* Export all the views

* Add screenshots

* Remove old view code

* Implement vm for tombstone tile in a room

* Implement vm for tombstone tile in a DM

* Implement root tile vm

* Use new tile in the event factory

* Remove old vm

* Exclude mock code from code checks
This commit is contained in:
R Midhun Suresh
2026-07-07 16:27:26 +00:00
committed by GitHub
parent 1b10122a7b
commit e46acef445
50 changed files with 1239 additions and 777 deletions
@@ -231,6 +231,11 @@
"call_declined": "Call declined",
"call_declined_by_us": "You declined a call"
},
"tombstone": {
"room": {
"title": "Group call ended"
}
},
"video_call_title": "Video call",
"voice_call_title": "Voice call"
},
@@ -1,71 +0,0 @@
/*
* 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 { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { CallType, type CallTileViewSnapshot } from "../common/types";
import { CallDeclinedTileView } from "./CallDeclinedTileView";
const CallDeclinedTileViewWrapperImpl = ({ ...rest }: CallTileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(rest, {});
return <CallDeclinedTileView vm={vm} />;
};
const CallDeclinedTileViewWrapper = withViewDocs(CallDeclinedTileViewWrapperImpl, CallDeclinedTileView);
const meta = {
title: "Timeline/Timeline Event/Call/CallDeclinedTileView",
component: CallDeclinedTileViewWrapper,
tags: ["autodocs"],
argTypes: {
type: {
options: [CallType.Video, CallType.Voice],
control: { type: "select" },
},
timestamp: {
control: { type: "text" },
},
},
args: {
type: CallType.Voice,
timestamp: "12:36",
isCallDeclinedByUs: false,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3914&t=jv0JnUoKJUW1Ko96-4",
},
},
} satisfies Meta<typeof CallDeclinedTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const VoiceCall: Story = {
args: {
type: CallType.Voice,
},
};
export const VideoCall: Story = {
args: {
type: CallType.Video,
},
};
export const CallDeclinedByUs: Story = {
args: {
type: CallType.Voice,
isCallDeclinedByUs: true,
},
};
@@ -1,34 +0,0 @@
/*
* 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 "./CallDeclinedTileView.stories";
const { VideoCall, VoiceCall, CallDeclinedByUs } = composeStories(Stories);
describe("CallDeclinedTileView", () => {
describe("renders the tile", () => {
it("voice call", () => {
const { container } = render(<VoiceCall />);
expect(container).toMatchSnapshot();
});
it("video call", () => {
const { container } = render(<VideoCall />);
expect(container).toMatchSnapshot();
});
it("call declined by us", () => {
const { container } = render(<CallDeclinedByUs />);
expect(container).toMatchSnapshot();
});
});
});
@@ -1,55 +0,0 @@
/*
* 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 {
VideoCallDeclinedSolidIcon,
VoiceCallDeclinedSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classnames from "classnames";
import { useViewModel, type ViewModel } from "../../../../../core/viewmodel";
import { Flex } from "../../../../../core/utils/Flex";
import styles from "../common/CallTileView.module.css";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { type CallTileViewSnapshot, CallType } from "../common/types";
export type CallDeclinedTileViewModel = ViewModel<CallTileViewSnapshot>;
export interface CallDeclinedTileViewProps {
vm: CallDeclinedTileViewModel;
className?: string;
}
function getIconForCallType(type: CallType): React.ReactNode {
switch (type) {
case CallType.Video:
return <VideoCallDeclinedSolidIcon className={styles.icon} width={20} height={20} />;
case CallType.Voice:
return <VoiceCallDeclinedSolidIcon className={styles.icon} width={20} height={20} />;
}
}
/**
* View for a timeline tile that indicates that a call was declined.
*/
export function CallDeclinedTileView({ vm, className }: CallDeclinedTileViewProps): React.ReactNode {
const { translate: _t } = useI18n();
const { type, timestamp, isCallDeclinedByUs } = useViewModel(vm);
const classNames = classnames(className, styles.container);
return (
<Flex className={classNames} align="center" gap="var(--cpd-space-2x)">
{getIconForCallType(type)}
<div className={styles.title}>
{isCallDeclinedByUs
? _t("timeline|call_tile|declined|call_declined_by_us")
: _t("timeline|call_tile|declined|call_declined")}
</div>
<div className={styles.time}>{timestamp}</div>
</Flex>
);
}
@@ -1,97 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CallDeclinedTileView > renders the tile > call declined by us 1`] = `
<div>
<div
class="Flex-module_flex CallTileView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="CallTileView-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.623 3.04a1.07 1.07 0 0 1 1.086.929l.542 3.954q.039.27-.038.504a1.1 1.1 0 0 1-.272.427l-1.64 1.64Q7.806 11.5 8.456 12.4c.433.601 1.444 1.697 1.444 1.697.013.012 1.098 1.014 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.194-.194.426-.27a1.1 1.1 0 0 1 .504-.04l3.953.543q.407.058.67.358.26.301.26.728l.04 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.116-6.56q0-.426.329-.756Q3.67 3 4.095 3zM20.25 3q.405 0 .707.3.3.301.3.708t-.3.707l-1.414 1.414 1.414 1.414q.3.3.3.707t-.3.707-.707.3-.707-.3l-1.414-1.414-1.414 1.414q-.3.3-.707.3t-.707-.3T15 8.25q0-.406.3-.707l1.415-1.414L15.3 4.715q-.3-.3-.301-.707 0-.407.3-.707t.71-.301q.405 0 .707.3l1.414 1.415L19.543 3.3q.3-.3.707-.301"
/>
</svg>
<div
class="CallTileView-module_title"
>
You declined a call
</div>
<div
class="CallTileView-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`CallDeclinedTileView > renders the tile > video call 1`] = `
<div>
<div
class="Flex-module_flex CallTileView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="CallTileView-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 8a4 4 0 0 1 4-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-4zm10.828 6.828q.3-.3.3-.707 0-.405-.3-.707L11.414 12l1.414-1.414q.3-.3.3-.707t-.3-.707-.707-.301q-.405 0-.707.3L10 10.587 8.586 9.172q-.3-.3-.707-.301-.407 0-.707.3t-.3.708q0 .405.3.707L8.586 12l-1.414 1.414q-.3.3-.3.707t.3.707.707.3q.405 0 .707-.3L10 13.414l1.414 1.414q.3.3.707.3t.707-.3"
/>
</svg>
<div
class="CallTileView-module_title"
>
Call declined
</div>
<div
class="CallTileView-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`CallDeclinedTileView > renders the tile > voice call 1`] = `
<div>
<div
class="Flex-module_flex CallTileView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="CallTileView-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.623 3.04a1.07 1.07 0 0 1 1.086.929l.542 3.954q.039.27-.038.504a1.1 1.1 0 0 1-.272.427l-1.64 1.64Q7.806 11.5 8.456 12.4c.433.601 1.444 1.697 1.444 1.697.013.012 1.098 1.014 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.194-.194.426-.27a1.1 1.1 0 0 1 .504-.04l3.953.543q.407.058.67.358.26.301.26.728l.04 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.116-6.56q0-.426.329-.756Q3.67 3 4.095 3zM20.25 3q.405 0 .707.3.3.301.3.708t-.3.707l-1.414 1.414 1.414 1.414q.3.3.3.707t-.3.707-.707.3-.707-.3l-1.414-1.414-1.414 1.414q-.3.3-.707.3t-.707-.3T15 8.25q0-.406.3-.707l1.415-1.414L15.3 4.715q-.3-.3-.301-.707 0-.407.3-.707t.71-.301q.405 0 .707.3l1.414 1.415L19.543 3.3q.3-.3.707-.301"
/>
</svg>
<div
class="CallTileView-module_title"
>
Call declined
</div>
<div
class="CallTileView-module_time"
>
12:36
</div>
</div>
</div>
`;
@@ -1,63 +0,0 @@
/*
* 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 { CallStartedTileView } from "./CallStartedTileView";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { CallType, type CallTileViewSnapshot } from "../common/types";
const CallStartedTileViewWrapperImpl = ({ ...rest }: CallTileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(rest, {});
return <CallStartedTileView vm={vm} />;
};
const CallStartedTileViewWrapper = withViewDocs(CallStartedTileViewWrapperImpl, CallStartedTileView);
const meta = {
title: "Timeline/Timeline Event/Call/CallStartedTileView",
component: CallStartedTileViewWrapper,
tags: ["autodocs"],
argTypes: {
type: {
options: [CallType.Video, CallType.Voice],
control: { type: "select" },
},
timestamp: {
control: { type: "text" },
},
},
args: {
type: CallType.Voice,
timestamp: "12:36",
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4",
},
},
} satisfies Meta<typeof CallStartedTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const VoiceCall: Story = {
args: {
type: CallType.Voice,
},
};
export const VideoCall: Story = {
args: {
type: CallType.Video,
},
};
@@ -1,29 +0,0 @@
/*
* 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 "./CallStartedTileView.stories";
const { VideoCall, VoiceCall } = composeStories(Stories);
describe("CallStartedTileView", () => {
describe("renders the tile", () => {
it("voice call", () => {
const { container } = render(<VoiceCall />);
expect(container).toMatchSnapshot();
});
it("video call", () => {
const { container } = render(<VideoCall />);
expect(container).toMatchSnapshot();
});
});
});
@@ -1,53 +0,0 @@
/*
* 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 { VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classnames from "classnames";
import { useViewModel, type ViewModel } from "../../../../../core/viewmodel";
import { Flex } from "../../../../../core/utils/Flex";
import styles from "../common/CallTileView.module.css";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { type CallTileViewSnapshot, CallType } from "../common/types";
export type CallStartedTileViewModel = ViewModel<CallTileViewSnapshot>;
export interface CallStartedTileViewProps {
vm: CallStartedTileViewModel;
className?: string;
}
function getIconForCallType(type: CallType): React.ReactNode {
switch (type) {
case CallType.Video:
return <VideoCallSolidIcon className={styles.icon} width={20} height={20} />;
case CallType.Voice:
return <VoiceCallSolidIcon className={styles.icon} width={20} height={20} />;
}
}
/**
* View for a timeline tile that indicates the start of an element call.
*/
export function CallStartedTileView({ vm, className }: CallStartedTileViewProps): React.ReactNode {
const { translate: _t } = useI18n();
const { type, timestamp } = useViewModel(vm);
const classNames = classnames(className, styles.container);
return (
<Flex className={classNames} align="center" gap="var(--cpd-space-2x)">
{getIconForCallType(type)}
<div className={styles.title}>
{type === CallType.Voice
? _t("timeline|call_tile|voice_call_title")
: _t("timeline|call_tile|video_call_title")}
</div>
<div className={styles.time}>{timestamp}</div>
</Flex>
);
}
@@ -1,65 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CallStartedTileView > renders the tile > video call 1`] = `
<div>
<div
class="Flex-module_flex CallTileView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="CallTileView-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
class="CallTileView-module_title"
>
Video call
</div>
<div
class="CallTileView-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`CallStartedTileView > renders the tile > voice call 1`] = `
<div>
<div
class="Flex-module_flex CallTileView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="CallTileView-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
class="CallTileView-module_title"
>
Voice call
</div>
<div
class="CallTileView-module_time"
>
12:36
</div>
</div>
</div>
`;
@@ -0,0 +1,47 @@
/*
* 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 {
RoomTombstoneCallTileView,
type RoomTombstoneCallTileViewModel,
} from "./tombstone/room/RoomTombstoneCallTileView";
import { DmTombstoneCallTileView, type DmTombstoneCallTileViewModel } from "./tombstone/dm/DmTombstoneCallTileView";
/**
* Map from tile type to view model.
*/
interface TileTypeToViewModelMap {
"tombstone-call-room": RoomTombstoneCallTileViewModel;
"tombstone-call-dm": DmTombstoneCallTileViewModel;
}
export interface RootCallTileViewSnapshot<Type extends keyof TileTypeToViewModelMap = keyof TileTypeToViewModelMap> {
tileType: Type;
tileViewModel: TileTypeToViewModelMap[Type];
}
export type RootCallTileViewModel = ViewModel<RootCallTileViewSnapshot>;
interface Props {
vm: RootCallTileViewModel;
}
/**
* Root view for a call tile in the timeline.
*/
export function RootCallTileView({ vm }: Props): React.ReactNode {
const { tileType, tileViewModel } = useViewModel(vm);
switch (tileType) {
case "tombstone-call-room":
return <RoomTombstoneCallTileView vm={tileViewModel as TileTypeToViewModelMap["tombstone-call-room"]} />;
case "tombstone-call-dm":
return <DmTombstoneCallTileView vm={tileViewModel as TileTypeToViewModelMap["tombstone-call-dm"]} />;
}
}
@@ -20,20 +20,9 @@ export const enum CallType {
}
/**
* The snapshot that both the call started and call declined tiles expect.
* Whether the call is incoming or outgoing.
*/
export type CallTileViewSnapshot = {
/**
* What type of call this tile needs to render for.
*/
type: CallType;
/**
* Time when this call was started.
*/
timestamp: string;
/**
* Whether this call was declined by our user.
* Undefined if not rendering a declined call tile.
*/
isCallDeclinedByUs?: boolean;
};
export const enum CallDirection {
Incoming = "Incoming",
Outgoing = "Outgoing",
}
@@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
export * from "./CallStartedTile/CallStartedTileView";
export * from "./CallDeclinedTile/CallDeclinedTileView";
export * from "./common/types";
export * from "./tombstone/room/RoomTombstoneCallTileView";
export * from "./tombstone/dm/DmTombstoneCallTileView";
export * from "./RootCallTileView";
export * from "./common";
@@ -0,0 +1,102 @@
/*
* 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 { DmTombstoneCallTileView, type DmTombstoneCallTileViewSnapshot } from "./DmTombstoneCallTileView";
import { useMockedViewModel } from "../../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../../.storybook/withViewDocs";
import { CallDirection, CallType } from "../../common";
const RoomTombstoneCallTileViewWrapperImpl = (snapshot: DmTombstoneCallTileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(snapshot, {});
return <DmTombstoneCallTileView vm={vm} />;
};
const RoomTombstoneCallTileViewWrapper = withViewDocs(RoomTombstoneCallTileViewWrapperImpl, DmTombstoneCallTileView);
const meta = {
title: "Timeline/Timeline Event/Call/Tombstone/DmTombstoneCallTileView",
component: RoomTombstoneCallTileViewWrapper,
tags: ["autodocs"],
argTypes: {
timestamp: {
control: { type: "text" },
},
callDirection: {
options: [CallDirection.Incoming, CallDirection.Outgoing],
control: { type: "radio" },
},
type: {
options: [CallType.Voice, CallType.Video],
control: { type: "radio" },
},
},
args: {
timestamp: "12:36",
callDirection: CallDirection.Incoming,
isCallDeclined: false,
type: CallType.Voice,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3905&t=iEfhUcFrV01fQeyQ-4",
},
},
} satisfies Meta<typeof RoomTombstoneCallTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const VoiceEnded: Story = {
args: {
type: CallType.Voice,
},
};
export const VideoEnded: Story = {
args: {
type: CallType.Video,
},
};
export const IncomingVoiceDeclined: Story = {
args: {
type: CallType.Voice,
callDirection: CallDirection.Incoming,
isCallDeclined: true,
},
};
export const IncomingVideoDeclined: Story = {
args: {
type: CallType.Video,
callDirection: CallDirection.Incoming,
isCallDeclined: true,
},
};
export const OutgoingVoiceDeclined: Story = {
args: {
type: CallType.Voice,
callDirection: CallDirection.Outgoing,
isCallDeclined: true,
},
};
export const OutgoingVideoDeclined: Story = {
args: {
type: CallType.Video,
callDirection: CallDirection.Outgoing,
isCallDeclined: true,
},
};
@@ -0,0 +1,39 @@
/*
* 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 "./DmTombstoneCallTileView.stories";
const { IncomingVideoDeclined, OutgoingVideoDeclined, VideoEnded, VoiceEnded } = composeStories(Stories);
describe("DmTombstoneCallTileView", () => {
describe("renders the tile", () => {
it("IncomingVideoDeclined", () => {
const { container } = render(<IncomingVideoDeclined />);
expect(container).toMatchSnapshot();
});
it("OutgoingVideoDeclined", () => {
const { container } = render(<OutgoingVideoDeclined />);
expect(container).toMatchSnapshot();
});
it("VideoEnded", () => {
const { container } = render(<VideoEnded />);
expect(container).toMatchSnapshot();
});
it("VoiceEnded", () => {
const { container } = render(<VoiceEnded />);
expect(container).toMatchSnapshot();
});
});
});
@@ -0,0 +1,96 @@
/*
* 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 {
VideoCallSolidIcon,
VideoCallDeclinedSolidIcon,
VoiceCallDeclinedSolidIcon,
VoiceCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classnames from "classnames";
import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel";
import { Flex } from "../../../../../../core/utils/Flex";
import styles from "../common.module.css";
import { useI18n } from "../../../../../../core/i18n/i18nContext";
import { CallDirection, CallType } from "../../common";
import { type RoomTombstoneCallTileViewSnapshot } from "../room/RoomTombstoneCallTileView";
export interface DmTombstoneCallTileViewSnapshot extends RoomTombstoneCallTileViewSnapshot {
/**
* What type of call this tile needs to render for.
*/
type: CallType;
/**
* Whether this is an incoming or outgoing call.
*/
callDirection: CallDirection;
/**
* Whether this call was declined.
*/
isCallDeclined: boolean;
}
export type DmTombstoneCallTileViewModel = ViewModel<DmTombstoneCallTileViewSnapshot>;
export interface DmTombstoneCallTileViewProps {
vm: DmTombstoneCallTileViewModel;
/**
* Additional class names for this component.
*/
className?: string;
}
function getIcon(type: CallType, isCallDeclined: boolean): React.ReactNode {
const VideoIcon = isCallDeclined ? VideoCallDeclinedSolidIcon : VideoCallSolidIcon;
const VoiceIcon = isCallDeclined ? VoiceCallDeclinedSolidIcon : VoiceCallSolidIcon;
switch (type) {
case CallType.Video:
return <VideoIcon className={styles.icon} width={20} height={20} />;
case CallType.Voice:
return <VoiceIcon className={styles.icon} width={20} height={20} />;
}
}
/**
* Renders the tombstone content for a tile in a DM.
*/
export function DmTombstoneCallTileView({ vm, className }: DmTombstoneCallTileViewProps): React.ReactNode {
const snapshot = useViewModel(vm);
const { type, timestamp, isCallDeclined } = snapshot;
const classNames = classnames(className, styles.container);
return (
<Flex className={classNames} align="center" gap="var(--cpd-space-2x)">
{getIcon(type, isCallDeclined)}
<div className={styles.title}>
{isCallDeclined ? <DeclinedContent snapshot={snapshot} /> : <NormalContent snapshot={snapshot} />}
</div>
<div className={styles.time}>{timestamp}</div>
</Flex>
);
}
function NormalContent(props: { snapshot: DmTombstoneCallTileViewSnapshot }): React.ReactNode {
const { type } = props.snapshot;
const { translate: _t } = useI18n();
return type === CallType.Voice
? _t("timeline|call_tile|voice_call_title")
: _t("timeline|call_tile|video_call_title");
}
function DeclinedContent(props: { snapshot: DmTombstoneCallTileViewSnapshot }): React.ReactNode {
const { callDirection } = props.snapshot;
const { translate: _t } = useI18n();
return callDirection === CallDirection.Incoming
? _t("timeline|call_tile|declined|call_declined_by_us")
: _t("timeline|call_tile|declined|call_declined");
}
@@ -0,0 +1,129 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DmTombstoneCallTileView > renders the tile > IncomingVideoDeclined 1`] = `
<div>
<div
class="Flex-module_flex common-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="common-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 8a4 4 0 0 1 4-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-4zm10.828 6.828q.3-.3.3-.707 0-.405-.3-.707L11.414 12l1.414-1.414q.3-.3.3-.707t-.3-.707-.707-.301q-.405 0-.707.3L10 10.587 8.586 9.172q-.3-.3-.707-.301-.407 0-.707.3t-.3.708q0 .405.3.707L8.586 12l-1.414 1.414q-.3.3-.3.707t.3.707.707.3q.405 0 .707-.3L10 13.414l1.414 1.414q.3.3.707.3t.707-.3"
/>
</svg>
<div
class="common-module_title"
>
You declined a call
</div>
<div
class="common-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`DmTombstoneCallTileView > renders the tile > OutgoingVideoDeclined 1`] = `
<div>
<div
class="Flex-module_flex common-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="common-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 8a4 4 0 0 1 4-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-4zm10.828 6.828q.3-.3.3-.707 0-.405-.3-.707L11.414 12l1.414-1.414q.3-.3.3-.707t-.3-.707-.707-.301q-.405 0-.707.3L10 10.587 8.586 9.172q-.3-.3-.707-.301-.407 0-.707.3t-.3.708q0 .405.3.707L8.586 12l-1.414 1.414q-.3.3-.3.707t.3.707.707.3q.405 0 .707-.3L10 13.414l1.414 1.414q.3.3.707.3t.707-.3"
/>
</svg>
<div
class="common-module_title"
>
Call declined
</div>
<div
class="common-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`DmTombstoneCallTileView > renders the tile > VideoEnded 1`] = `
<div>
<div
class="Flex-module_flex common-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="common-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
class="common-module_title"
>
Video call
</div>
<div
class="common-module_time"
>
12:36
</div>
</div>
</div>
`;
exports[`DmTombstoneCallTileView > renders the tile > VoiceEnded 1`] = `
<div>
<div
class="Flex-module_flex common-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="common-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
class="common-module_title"
>
Voice call
</div>
<div
class="common-module_time"
>
12:36
</div>
</div>
</div>
`;
@@ -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 type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomTombstoneCallTileView, type RoomTombstoneCallTileViewSnapshot } from "./RoomTombstoneCallTileView";
import { useMockedViewModel } from "../../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../../.storybook/withViewDocs";
const RoomTombstoneCallTileViewWrapperImpl = ({ ...rest }: RoomTombstoneCallTileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(rest, {});
return <RoomTombstoneCallTileView vm={vm} />;
};
const RoomTombstoneCallTileViewWrapper = withViewDocs(RoomTombstoneCallTileViewWrapperImpl, RoomTombstoneCallTileView);
const meta = {
title: "Timeline/Timeline Event/Call/Tombstone/RoomTombstoneCallTileView",
component: RoomTombstoneCallTileViewWrapper,
tags: ["autodocs"],
argTypes: {
timestamp: {
control: { type: "text" },
},
},
args: {
timestamp: "12:36",
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3902&t=oRWAGiwV5pUV4OFF-4",
},
},
} satisfies Meta<typeof RoomTombstoneCallTileViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -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 { composeStories } from "@storybook/react-vite";
import { describe, expect, it } from "vitest";
import React from "react";
import { render } from "@test-utils";
import * as Stories from "./RoomTombstoneCallTileView.stories";
const { Default } = composeStories(Stories);
describe("RoomTombstoneCallTileView", () => {
it("renders the tile", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,49 @@
/*
* 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 { VideoCallDeclinedSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classnames from "classnames";
import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel";
import { Flex } from "../../../../../../core/utils/Flex";
import styles from "../common.module.css";
import { useI18n } from "../../../../../../core/i18n/i18nContext";
export type RoomTombstoneCallTileViewSnapshot = {
/**
* Time when this call was started.
*/
timestamp: string;
};
export type RoomTombstoneCallTileViewModel = ViewModel<RoomTombstoneCallTileViewSnapshot>;
export interface CallStartedTileViewProps {
vm: RoomTombstoneCallTileViewModel;
/**
* Additional class names for this component.
*/
className?: string;
}
/**
* Renders the tombstone content for a call in a room.
*/
export function RoomTombstoneCallTileView({ vm, className }: CallStartedTileViewProps): React.ReactNode {
const { translate: _t } = useI18n();
const { timestamp } = useViewModel(vm);
const classNames = classnames(className, styles.container);
return (
<Flex className={classNames} align="center" gap="var(--cpd-space-2x)">
<VideoCallDeclinedSolidIcon className={styles.icon} width={20} height={20} />
<div className={styles.title}>{_t("timeline|call_tile|tombstone|room|title")}</div>
<div className={styles.time}>{timestamp}</div>
</Flex>
);
}
@@ -0,0 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoomTombstoneCallTileView > renders the tile 1`] = `
<div>
<div
class="Flex-module_flex common-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="common-module_icon"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 8a4 4 0 0 1 4-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-4zm10.828 6.828q.3-.3.3-.707 0-.405-.3-.707L11.414 12l1.414-1.414q.3-.3.3-.707t-.3-.707-.707-.301q-.405 0-.707.3L10 10.587 8.586 9.172q-.3-.3-.707-.301-.407 0-.707.3t-.3.708q0 .405.3.707L8.586 12l-1.414 1.414q-.3.3-.3.707t.3.707.707.3q.405 0 .707-.3L10 13.414l1.414 1.414q.3.3.707.3t.707-.3"
/>
</svg>
<div
class="common-module_title"
>
Group call ended
</div>
<div
class="common-module_time"
>
12:36
</div>
</div>
</div>
`;