Call Tile - Render a tile showing that a call was started (#32988)
* Ignore specific directories Otherwise newly generated screenshots will be ignored. * Create a CallStartedTileView This view will render a tile that shows when an EC call was started in the timeline. * Add storybook tests * Add vite tests * Export the view from shared-components package * Add a viewmodel for driving the view * Support rendering the tile in the tile factory * Fix tile rendering * Add comment to explain css height * Use semantic token for gap * Update snapshot * Use min-height over height This will scale the element if the user sets a custom font size. * Don't show timestamp for call started tile Timestamp is already shown as a part of the tile content. * Fix broken tile on bubble layout The tile should be full-width and not shown within a bubble. * Fix tests * Update storybook title
This commit is contained in:
@@ -717,6 +717,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
|
|
||||||
if (this.props.isRedacted) return false;
|
if (this.props.isRedacted) return false;
|
||||||
|
|
||||||
|
// This event is a room mention but we don't want the call tile to have a highlight.
|
||||||
|
if (this.props.mxEvent.getType() === EventType.RTCNotification) return false;
|
||||||
|
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
const actions = cli.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
|
const actions = cli.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
|
||||||
// get the actions for the previous version of the event too if it is an edit
|
// get the actions for the previous version of the event too if it is an edit
|
||||||
@@ -1119,7 +1122,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
} else if (
|
} else if (
|
||||||
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
|
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
|
||||||
eventType === EventType.CallInvite ||
|
eventType === EventType.CallInvite ||
|
||||||
ElementCallEventType.matches(eventType)
|
ElementCallEventType.matches(eventType) ||
|
||||||
|
eventType === EventType.RTCNotification
|
||||||
) {
|
) {
|
||||||
// no avatar or sender profile for continuation messages and call tiles
|
// no avatar or sender profile for continuation messages and call tiles
|
||||||
avatarSize = null;
|
avatarSize = null;
|
||||||
@@ -1197,6 +1201,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
|
|
||||||
const showTimestamp =
|
const showTimestamp =
|
||||||
this.props.mxEvent.getTs() &&
|
this.props.mxEvent.getTs() &&
|
||||||
|
// Don't show timestamp for the CallStarted tile because
|
||||||
|
// the tile content already renders a timestamp.
|
||||||
|
this.props.mxEvent.getType() !== EventType.RTCNotification &&
|
||||||
!this.props.hideTimestamp &&
|
!this.props.hideTimestamp &&
|
||||||
(this.props.alwaysShowTimestamps ||
|
(this.props.alwaysShowTimestamps ||
|
||||||
this.props.last ||
|
this.props.last ||
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
|
CallStartedTileView,
|
||||||
EncryptionEventView,
|
EncryptionEventView,
|
||||||
HiddenBodyView,
|
HiddenBodyView,
|
||||||
MKeyVerificationRequestView,
|
MKeyVerificationRequestView,
|
||||||
@@ -51,6 +52,7 @@ import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/ev
|
|||||||
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
|
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
|
||||||
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
|
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
|
||||||
import { ElementCallEventType } from "../call-types";
|
import { ElementCallEventType } from "../call-types";
|
||||||
|
import { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel";
|
||||||
|
|
||||||
// Subset of EventTile's IProps plus some mixins
|
// Subset of EventTile's IProps plus some mixins
|
||||||
export interface EventTileTypeProps extends Pick<
|
export interface EventTileTypeProps extends Pick<
|
||||||
@@ -122,6 +124,15 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
|
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
|
||||||
|
|
||||||
|
function CallStartedTileViewWrapped({ mxEvent }: IBodyProps): JSX.Element {
|
||||||
|
const vm = useCreateAutoDisposedViewModel(() => new CallStartedTileViewModel({ mxEvent }));
|
||||||
|
return <CallStartedTileView vm={vm} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallStartedEventFactory: Factory = (ref, props) => {
|
||||||
|
return <CallStartedTileViewWrapped {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
// These factories are exported for reference comparison against pickFactory()
|
// These factories are exported for reference comparison against pickFactory()
|
||||||
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
|
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
|
||||||
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
|
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
|
||||||
@@ -135,6 +146,7 @@ const EVENT_TILE_TYPES = new Map<string, Factory>([
|
|||||||
[M_POLL_END.name, MessageEventFactory],
|
[M_POLL_END.name, MessageEventFactory],
|
||||||
[M_POLL_END.altName, MessageEventFactory],
|
[M_POLL_END.altName, MessageEventFactory],
|
||||||
[EventType.CallInvite, LegacyCallEventFactory as Factory], // note that this requires a special factory type
|
[EventType.CallInvite, LegacyCallEventFactory as Factory], // note that this requires a special factory type
|
||||||
|
[EventType.RTCNotification, CallStartedEventFactory],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
|
const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const calcIsInfoMessage = (
|
|||||||
eventType !== EventType.RoomMessageEncrypted &&
|
eventType !== EventType.RoomMessageEncrypted &&
|
||||||
eventType !== EventType.Sticker &&
|
eventType !== EventType.Sticker &&
|
||||||
eventType !== EventType.RoomCreate &&
|
eventType !== EventType.RoomCreate &&
|
||||||
|
eventType !== EventType.RTCNotification &&
|
||||||
!M_POLL_START.matches(eventType) &&
|
!M_POLL_START.matches(eventType) &&
|
||||||
!M_POLL_END.matches(eventType) &&
|
!M_POLL_END.matches(eventType) &&
|
||||||
!M_BEACON_INFO.matches(eventType)
|
!M_BEACON_INFO.matches(eventType)
|
||||||
@@ -76,6 +77,7 @@ export function getEventDisplayInfo(
|
|||||||
|
|
||||||
// Info messages are basically information about commands processed on a room
|
// Info messages are basically information about commands processed on a room
|
||||||
let isBubbleMessage =
|
let isBubbleMessage =
|
||||||
|
eventType === EventType.RTCNotification ||
|
||||||
eventType.startsWith("m.key.verification") ||
|
eventType.startsWith("m.key.verification") ||
|
||||||
(eventType === EventType.RoomMessage && msgtype?.startsWith("m.key.verification")) ||
|
(eventType === EventType.RoomMessage && msgtype?.startsWith("m.key.verification")) ||
|
||||||
eventType === EventType.RoomCreate ||
|
eventType === EventType.RoomCreate ||
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* 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 { BaseViewModel, CallType, type CallStartedTileViewSnapshot } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import type { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import type { IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
|
||||||
|
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||||
|
import { formatTime } from "../../../../../DateUtils";
|
||||||
|
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
|
||||||
|
import type { SettingUpdatedPayload } from "../../../../../dispatcher/payloads/SettingUpdatedPayload";
|
||||||
|
import type { ActionPayload } from "../../../../../dispatcher/payloads";
|
||||||
|
import { Action } from "../../../../../dispatcher/actions";
|
||||||
|
|
||||||
|
export interface CallStartedTileViewModelProps {
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntentFromEvent(event: MatrixEvent): CallStartedTileViewSnapshot["type"] {
|
||||||
|
const content = event.getContent<IRTCNotificationContent>();
|
||||||
|
const intentInContent = content["m.call.intent"];
|
||||||
|
switch (intentInContent) {
|
||||||
|
case "audio":
|
||||||
|
return CallType.Voice;
|
||||||
|
case "video":
|
||||||
|
default:
|
||||||
|
return CallType.Video;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): CallStartedTileViewSnapshot["timestamp"] {
|
||||||
|
const content = event.getContent<IRTCNotificationContent>();
|
||||||
|
const senderTs = content["sender_ts"];
|
||||||
|
const originServerTs = event.getTs();
|
||||||
|
const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs;
|
||||||
|
|
||||||
|
const date = new Date(ts);
|
||||||
|
const timestamp = formatTime(date, showTwelveHour);
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialSnapshot(event: MatrixEvent): CallStartedTileViewSnapshot {
|
||||||
|
const type = getIntentFromEvent(event);
|
||||||
|
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||||
|
const timestamp = getTimeFromEvent(event, showTwelveHour);
|
||||||
|
return { type, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload {
|
||||||
|
return payload.action === Action.SettingUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for a timeline tile that indicates the start of an element call.
|
||||||
|
*/
|
||||||
|
export class CallStartedTileViewModel extends BaseViewModel<
|
||||||
|
CallStartedTileViewSnapshot,
|
||||||
|
CallStartedTileViewModelProps
|
||||||
|
> {
|
||||||
|
public constructor(props: CallStartedTileViewModelProps) {
|
||||||
|
super(props, getInitialSnapshot(props.mxEvent));
|
||||||
|
SettingsStore.monitorSetting("showTwelveHourTimestamps", null);
|
||||||
|
const token = defaultDispatcher.register(this.onAction);
|
||||||
|
this.disposables.track(() => {
|
||||||
|
defaultDispatcher.unregister(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAction = (payload: ActionPayload): void => {
|
||||||
|
if (!isSettingsChangedPayload(payload) || payload.settingName !== "showTwelveHourTimestamps") return;
|
||||||
|
const showTwelveHour = (payload.newValue as boolean) ?? false;
|
||||||
|
const timestamp = getTimeFromEvent(this.props.mxEvent, showTwelveHour);
|
||||||
|
this.snapshot.merge({ timestamp });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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 MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { CallType } from "@element-hq/web-shared-components";
|
||||||
|
import { waitFor } from "jest-matrix-react";
|
||||||
|
|
||||||
|
import { mkEvent } from "../../test-utils";
|
||||||
|
import { CallStartedTileViewModel } from "../../../src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel";
|
||||||
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
|
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
|
|
||||||
|
function getMockedRtcNotificationEvent(intent: string, senderTs: number, serverTs: number): MatrixEvent {
|
||||||
|
const mockEvent = mkEvent({
|
||||||
|
type: EventType.RTCNotification,
|
||||||
|
user: "@foo:m.org",
|
||||||
|
content: {
|
||||||
|
"m.call.intent": intent,
|
||||||
|
"sender_ts": senderTs,
|
||||||
|
},
|
||||||
|
ts: serverTs,
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
return mockEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CallStartedTileViewModel", () => {
|
||||||
|
it("should set voice intent in state", () => {
|
||||||
|
const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365);
|
||||||
|
const vm = new CallStartedTileViewModel({ mxEvent });
|
||||||
|
const { type } = vm.getSnapshot();
|
||||||
|
expect(type).toStrictEqual(CallType.Voice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set video intent in state", () => {
|
||||||
|
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365);
|
||||||
|
const vm = new CallStartedTileViewModel({ mxEvent });
|
||||||
|
const { type } = vm.getSnapshot();
|
||||||
|
expect(type).toStrictEqual(CallType.Video);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate time string correctly", () => {
|
||||||
|
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
|
||||||
|
const vm = new CallStartedTileViewModel({ mxEvent });
|
||||||
|
const { timestamp } = vm.getSnapshot();
|
||||||
|
expect(timestamp).toStrictEqual("17:55");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate time string correctly when configured to use 12 hour format", async () => {
|
||||||
|
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
|
||||||
|
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true);
|
||||||
|
const vm = new CallStartedTileViewModel({ mxEvent });
|
||||||
|
const { timestamp } = vm.getSnapshot();
|
||||||
|
expect(timestamp).toStrictEqual("5:55 PM");
|
||||||
|
SettingsStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should change timestamp format when setting is modified", async () => {
|
||||||
|
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
|
||||||
|
const vm = new CallStartedTileViewModel({ mxEvent });
|
||||||
|
expect(vm.getSnapshot().timestamp).toStrictEqual("17:55");
|
||||||
|
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vm.getSnapshot().timestamp).toStrictEqual("5:55 PM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -199,6 +199,10 @@
|
|||||||
"n_minutes_ago": "%(num)s minutes ago"
|
"n_minutes_ago": "%(num)s minutes ago"
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
|
"call_tile": {
|
||||||
|
"video_call_title": "Video call",
|
||||||
|
"voice_call_title": "Voice call"
|
||||||
|
},
|
||||||
"decryption_failure": {
|
"decryption_failure": {
|
||||||
"blocked": "The sender has blocked you from receiving this message because your device is unverified",
|
"blocked": "The sender has blocked you from receiving this message because your device is unverified",
|
||||||
"historical_event_no_key_backup": "Historical messages are not available on this device",
|
"historical_event_no_key_backup": "Historical messages are not available on this device",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export * from "./room/timeline/TimelineSeparator";
|
|||||||
export * from "./room/timeline/event-tile/actions/ActionBarView";
|
export * from "./room/timeline/event-tile/actions/ActionBarView";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
|
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
|
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
|
||||||
|
export * from "./room/timeline/event-tile/call";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
|
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView";
|
export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge";
|
export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge";
|
||||||
|
|||||||
+30
@@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
/* This is the height of the tile as per design */
|
||||||
|
min-height: 39px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
border-radius: var(--cpd-space-2x);
|
||||||
|
padding: var(--cpd-space-2x) var(--cpd-space-3x);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font: var(--cpd-font-body-md-semibold);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font: var(--cpd-font-body-xs-regular);
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* 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, type CallStartedTileViewSnapshot, CallType } from "./CallStartedTileView";
|
||||||
|
import { useMockedViewModel } from "../../../../../core/viewmodel";
|
||||||
|
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||||
|
|
||||||
|
const CallStartedTileViewWrapperImpl = ({ ...rest }: CallStartedTileViewSnapshot): 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* 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 "./CallStartedTileView.module.css";
|
||||||
|
import { useI18n } from "../../../../../core/i18n/i18nContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents whether a call is a voice call or video call.
|
||||||
|
*/
|
||||||
|
export const enum CallType {
|
||||||
|
/**
|
||||||
|
* This is a voice call.
|
||||||
|
*/
|
||||||
|
Voice = "voice",
|
||||||
|
/**
|
||||||
|
* This is a video call.
|
||||||
|
*/
|
||||||
|
Video = "video",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallStartedTileViewSnapshot = {
|
||||||
|
/**
|
||||||
|
* What type of call this tile needs to render for.
|
||||||
|
*/
|
||||||
|
type: CallType;
|
||||||
|
/**
|
||||||
|
* Time when this call was started.
|
||||||
|
*/
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallStartedTileViewModel = ViewModel<CallStartedTileViewSnapshot>;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`CallStartedTileView > renders the tile > video call 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex CallStartedTileView-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="CallStartedTileView-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="CallStartedTileView-module_title"
|
||||||
|
>
|
||||||
|
Video call
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="CallStartedTileView-module_time"
|
||||||
|
>
|
||||||
|
12:36
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`CallStartedTileView > renders the tile > voice call 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex CallStartedTileView-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="CallStartedTileView-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="CallStartedTileView-module_title"
|
||||||
|
>
|
||||||
|
Voice call
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="CallStartedTileView-module_time"
|
||||||
|
>
|
||||||
|
12:36
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./CallStartedTile/CallStartedTileView";
|
||||||
Reference in New Issue
Block a user