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
@@ -62,6 +62,8 @@ const config: Config = {
|
||||
// Ignore vitest tests
|
||||
"!<rootDir>/src/**/*.test.{ts,tsx}",
|
||||
"!<rootDir>/src/test/**",
|
||||
// Exclude mocks
|
||||
"!<rootDir>/src/**/*-{mock,mocks}.{ts,tsx}",
|
||||
],
|
||||
coverageReporters: ["text-summary", ["lcov", { projectRoot: "../../" }]],
|
||||
prettierPath: null,
|
||||
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
M_POLL_START,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
CallDeclinedTileView,
|
||||
CallStartedTileView,
|
||||
EncryptionEventView,
|
||||
HiddenBodyView,
|
||||
MJitsiWidgetEventView,
|
||||
MKeyVerificationRequestView,
|
||||
RoomAvatarEventView,
|
||||
RootCallTileView,
|
||||
TextualEventView,
|
||||
ViewSourceEventView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
@@ -57,7 +56,7 @@ import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/Te
|
||||
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
|
||||
import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
|
||||
import { ElementCallEventType } from "../call-types";
|
||||
import { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel";
|
||||
import { RootCallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/RootCallTileViewModel";
|
||||
|
||||
// Subset of EventTile's IProps plus some mixins
|
||||
export interface EventTileTypeProps extends Pick<
|
||||
@@ -189,8 +188,16 @@ function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
|
||||
const RoomAvatarEventFactory: Factory = (ref, props) => <RoomAvatarEventWrappedView ref={ref} {...props} />;
|
||||
|
||||
function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent }));
|
||||
return vm.isCallDeclined ? <CallDeclinedTileView vm={vm} /> : <CallStartedTileView vm={vm} />;
|
||||
const cli = useMatrixClientContext();
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new RootCallTileViewModel({
|
||||
mxEvent,
|
||||
getRelationsForEvent,
|
||||
cli,
|
||||
}),
|
||||
);
|
||||
return <RootCallTileView vm={vm} />;
|
||||
}
|
||||
|
||||
export const CallStartedEventFactory: Factory = (ref, props) => {
|
||||
|
||||
@@ -1,141 +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 { BaseViewModel, CallType, type CallTileViewSnapshot } from "@element-hq/web-shared-components";
|
||||
import { EventType, type MatrixEvent, MatrixEventEvent, RelationType } 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";
|
||||
import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
||||
|
||||
export interface CallTileViewModelProps {
|
||||
/**
|
||||
* Event of type `org.matrix.msc4075.rtc.notification`.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
/**
|
||||
* Helper to fetch related events from a given event.
|
||||
*/
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
}
|
||||
|
||||
function getIntentFromEvent(event: MatrixEvent): CallTileViewSnapshot["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 getTs(event: MatrixEvent): number {
|
||||
if (event.getType() === EventType.RTCNotification) {
|
||||
/**
|
||||
* According to the spec:
|
||||
* Receivers SHOULD use origin_server_ts if |sender_ts - origin_server_ts| > 20000 ms.
|
||||
*/
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const senderTs = content["sender_ts"];
|
||||
const originServerTs = event.getTs();
|
||||
const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs;
|
||||
return ts;
|
||||
} else return event.getTs();
|
||||
}
|
||||
|
||||
function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): CallTileViewSnapshot["timestamp"] {
|
||||
const ts = getTs(event);
|
||||
const date = new Date(ts);
|
||||
const timestamp = formatTime(date, showTwelveHour);
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function generateSnapshot(
|
||||
event: MatrixEvent,
|
||||
getRelationsForEvent?: GetRelationsForEvent,
|
||||
): { snapshot: CallTileViewSnapshot; declineEvent: MatrixEvent | null } {
|
||||
const type = getIntentFromEvent(event);
|
||||
const declineEvent = getDeclinedEvent(event, getRelationsForEvent);
|
||||
let isCallDeclinedByUs: boolean | undefined;
|
||||
if (declineEvent) {
|
||||
isCallDeclinedByUs = declineEvent.getSender() === MatrixClientPeg.get()?.getUserId();
|
||||
}
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const timestamp = getTimeFromEvent(declineEvent ?? event, showTwelveHour);
|
||||
return { snapshot: { type, timestamp, isCallDeclinedByUs }, declineEvent };
|
||||
}
|
||||
|
||||
function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload {
|
||||
return payload.action === Action.SettingUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a declined event that is related to the given rtc notification event.
|
||||
* @param event rtc notification event
|
||||
*/
|
||||
function getDeclinedEvent(event: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): MatrixEvent | null {
|
||||
const eventId = event.getId();
|
||||
if (eventId && getRelationsForEvent) {
|
||||
const relations = getRelationsForEvent(eventId, RelationType.Reference, EventType.RTCDecline)?.getRelations();
|
||||
if (relations) return relations[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common view-model for call tiles; currently used to render:
|
||||
* 1. A tile that indicates that a call occurred (call tombstone).
|
||||
* 2. A tile that indicates that a call was declined.
|
||||
*/
|
||||
export class CallTileViewModel extends BaseViewModel<CallTileViewSnapshot, CallTileViewModelProps> {
|
||||
/**
|
||||
* The decline event associated with this call, if any.
|
||||
*/
|
||||
private declineEvent: MatrixEvent | null;
|
||||
|
||||
public constructor(props: CallTileViewModelProps) {
|
||||
const { declineEvent, snapshot } = generateSnapshot(props.mxEvent, props.getRelationsForEvent);
|
||||
super(props, snapshot);
|
||||
this.declineEvent = declineEvent;
|
||||
|
||||
// Listen to the changes on settings so that we can update the timestamp format (12H vs 24H).
|
||||
SettingsStore.monitorSetting("showTwelveHourTimestamps", null);
|
||||
const token = defaultDispatcher.register(this.onAction);
|
||||
this.disposables.track(() => {
|
||||
defaultDispatcher.unregister(token);
|
||||
});
|
||||
|
||||
// When a relation is added to the event, recompute the state.
|
||||
this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => {
|
||||
const { declineEvent, snapshot } = generateSnapshot(props.mxEvent, props.getRelationsForEvent);
|
||||
this.declineEvent = declineEvent;
|
||||
this.snapshot.set(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (!isSettingsChangedPayload(payload) || payload.settingName !== "showTwelveHourTimestamps") return;
|
||||
const showTwelveHour = (payload.newValue as boolean) ?? false;
|
||||
const timestamp = getTimeFromEvent(this.declineEvent ?? this.props.mxEvent, showTwelveHour);
|
||||
this.snapshot.merge({ timestamp });
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the call associated with this vm has been declined.
|
||||
*/
|
||||
public get isCallDeclined(): boolean {
|
||||
return !!this.declineEvent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { it, describe, expect, vi } from "vitest";
|
||||
import { type EventTimeline, EventType, type MatrixEvent, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { mkEvent, mkMessage, mkRoomMember, mkStubRoom, stubClient } from "../../../../../../test/test-utils";
|
||||
import { getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "./call-mocks";
|
||||
import { RootCallTileViewModel } from "./RootCallTileViewModel";
|
||||
|
||||
function getEvents(): MatrixEvent[] {
|
||||
const message1 = mkMessage({
|
||||
room: "!my-room:m.org",
|
||||
user: "@alice:m.org",
|
||||
event: true,
|
||||
msg: "hello",
|
||||
});
|
||||
|
||||
const message2 = mkMessage({
|
||||
room: "!my-room:m.org",
|
||||
user: "@bob:m.org",
|
||||
event: true,
|
||||
msg: "hello",
|
||||
});
|
||||
|
||||
const oldRtcNotificationEvent = mkEvent({
|
||||
type: EventType.RTCNotification,
|
||||
id: "old-event",
|
||||
content: {
|
||||
"m.call.intent": "video",
|
||||
},
|
||||
user: "@alice:m.org",
|
||||
event: true,
|
||||
});
|
||||
|
||||
const latestRtcNotificationEvent = mkEvent({
|
||||
type: EventType.RTCNotification,
|
||||
id: "new-event",
|
||||
content: {
|
||||
"m.call.intent": "video",
|
||||
},
|
||||
user: "@bob:m.org",
|
||||
event: true,
|
||||
});
|
||||
return [message1, message2, oldRtcNotificationEvent, latestRtcNotificationEvent];
|
||||
}
|
||||
|
||||
function getMocked(userIds: string[]) {
|
||||
const cli = stubClient();
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
|
||||
mxEvent.getId = () => "new-event";
|
||||
|
||||
const call = MockedCall.create();
|
||||
const callStore = MockedCallStore.create(call);
|
||||
|
||||
const members = userIds.map((id) => mkRoomMember("!my-room:m.org", id));
|
||||
|
||||
const room = mkStubRoom("!my-room:m.org", "My Room");
|
||||
cli.getRoom = () => room;
|
||||
|
||||
vi.spyOn(room, "getMembers").mockReturnValue(members);
|
||||
|
||||
const events = getEvents();
|
||||
vi.spyOn(room, "getLiveTimeline").mockImplementation(() => {
|
||||
return {
|
||||
getEvents: () => {
|
||||
return events;
|
||||
},
|
||||
getState: (): RoomState => {
|
||||
return {
|
||||
mayClientSendStateEvent: () => true,
|
||||
} as unknown as RoomState;
|
||||
},
|
||||
} as unknown as EventTimeline;
|
||||
});
|
||||
|
||||
return { callStore, cli, mxEvent, call };
|
||||
}
|
||||
|
||||
describe("RootCallTileViewModel", () => {
|
||||
it("computes correct tileType for tombstone call in DM", () => {
|
||||
const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org"]);
|
||||
const vm = new RootCallTileViewModel({ cli, mxEvent });
|
||||
|
||||
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-dm");
|
||||
});
|
||||
|
||||
it("computes correct tileType for tombstone call in Room", () => {
|
||||
const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org", "@jack:m.org"]);
|
||||
const vm = new RootCallTileViewModel({ cli, mxEvent });
|
||||
|
||||
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-room");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { BaseViewModel, type RootCallTileViewSnapshot } from "@element-hq/web-shared-components";
|
||||
|
||||
import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
|
||||
import { DmTombstoneCallTileViewModel } from "./tiles/tombstone/DmTombstoneCallTileViewModel";
|
||||
import { RoomTombstoneCallTileViewModel } from "./tiles/tombstone/RoomTombstoneCallTileViewModel";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Event of type `org.matrix.msc4075.rtc.notification`.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
/**
|
||||
* Helper to fetch related events from a given event.
|
||||
*/
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
|
||||
/**
|
||||
* The {@link MatrixClient} object to access js-sdk API.
|
||||
*/
|
||||
cli: MatrixClient;
|
||||
}
|
||||
|
||||
function computeSnapshot(props: Props): RootCallTileViewSnapshot {
|
||||
const cli = props.cli;
|
||||
const notificationEvent = props.mxEvent;
|
||||
|
||||
// Get the room where this call is taking place
|
||||
const roomId = props.mxEvent.getRoomId();
|
||||
if (!roomId) throw new Error("Notification event does not have associated room-id");
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) throw new Error(`No room with id ${roomId}`);
|
||||
|
||||
// This is the same logic used for hiding/showing the voice call button.
|
||||
const isDmRoom = room.getMembers().length <= 2;
|
||||
|
||||
if (isDmRoom) {
|
||||
return {
|
||||
tileType: "tombstone-call-dm",
|
||||
tileViewModel: new DmTombstoneCallTileViewModel({
|
||||
mxEvent: notificationEvent,
|
||||
getRelationsForEvent: props.getRelationsForEvent,
|
||||
cli: props.cli,
|
||||
}),
|
||||
};
|
||||
} else
|
||||
return {
|
||||
tileType: "tombstone-call-room",
|
||||
tileViewModel: new RoomTombstoneCallTileViewModel({ mxEvent: notificationEvent }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The root call tile view model which decides what call tile should be rendered.
|
||||
*/
|
||||
export class RootCallTileViewModel extends BaseViewModel<RootCallTileViewSnapshot, Props> {
|
||||
public constructor(props: Props) {
|
||||
const snapshot = computeSnapshot(props);
|
||||
super(props, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 { EventEmitter } from "events";
|
||||
import { type RoomMember, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { mkEvent } from "../../../../../../test/test-utils";
|
||||
import { type ElementCall } from "../../../../../models/Call";
|
||||
import type { CallStore } from "../../../../../stores/CallStore";
|
||||
import type { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
export function getMockedRtcNotificationEvent(
|
||||
intent: string,
|
||||
senderTs: number,
|
||||
serverTs: number,
|
||||
sender: string = "@foo:m.org",
|
||||
): MatrixEvent {
|
||||
const mockEvent = mkEvent({
|
||||
type: "org.matrix.msc4075.rtc.notification",
|
||||
user: sender,
|
||||
content: {
|
||||
"m.call.intent": intent,
|
||||
"sender_ts": senderTs,
|
||||
},
|
||||
ts: serverTs,
|
||||
event: true,
|
||||
room: "!my-room:m.org",
|
||||
});
|
||||
return mockEvent;
|
||||
}
|
||||
|
||||
export function getMockedRtcDeclineEvent(rtcNotificationEvent: MatrixEvent, sender = "@foo:m.org"): MatrixEvent {
|
||||
const mockEvent = mkEvent({
|
||||
type: EventType.RTCDecline,
|
||||
user: sender,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: rtcNotificationEvent.getId(),
|
||||
},
|
||||
},
|
||||
ts: 924285416000,
|
||||
event: true,
|
||||
});
|
||||
return mockEvent;
|
||||
}
|
||||
|
||||
interface MockCallStoreType extends CallStore {
|
||||
withActiveCall(): this;
|
||||
isActiveCall: boolean;
|
||||
call: ElementCall | null;
|
||||
}
|
||||
|
||||
export class MockedCallStore extends EventEmitter {
|
||||
public isActiveCall: boolean = false;
|
||||
|
||||
public constructor(public call: ElementCall | null) {
|
||||
super();
|
||||
}
|
||||
|
||||
public static create(call: ElementCall | null): MockCallStoreType {
|
||||
return new MockedCallStore(call) as unknown as MockCallStoreType;
|
||||
}
|
||||
|
||||
public withActiveCall(): this {
|
||||
this.isActiveCall = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getCall(): ElementCall | null {
|
||||
return this.call;
|
||||
}
|
||||
|
||||
public getActiveCall(): ElementCall | null {
|
||||
if (this.isActiveCall) return this.getCall();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface MockCallType extends ElementCall {
|
||||
withOldestMembershipTs(ts: number): this;
|
||||
withParticipants(participants: RoomMember[]): this;
|
||||
}
|
||||
|
||||
export class MockedCall extends EventEmitter {
|
||||
public participantMap = new Map<RoomMember, Set<string>>();
|
||||
public createdTs: number = Date.now();
|
||||
|
||||
public static create(): MockCallType {
|
||||
return new MockedCall() as unknown as MockCallType;
|
||||
}
|
||||
|
||||
public withOldestMembershipTs(ts: number): this {
|
||||
this.createdTs = ts;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParticipants(participants: RoomMember[]): this {
|
||||
for (const participant of participants) {
|
||||
this.participantMap.set(participant, new Set());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public get participants(): Map<RoomMember, Set<string>> {
|
||||
return this.participantMap;
|
||||
}
|
||||
|
||||
public get session(): MatrixRTCSession {
|
||||
return {
|
||||
getOldestMembership: (): CallMembership => {
|
||||
return {
|
||||
createdTs: () => this.createdTs,
|
||||
} as CallMembership;
|
||||
},
|
||||
} as MatrixRTCSession;
|
||||
}
|
||||
}
|
||||
@@ -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 { CallType } from "@element-hq/web-shared-components";
|
||||
import { EventType, RelationType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
import { type GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
|
||||
|
||||
/**
|
||||
* Find the call intent from a given rtc notification event.
|
||||
* @param event Rtc notification event
|
||||
*/
|
||||
export function getIntentFromEvent(event: MatrixEvent): CallType {
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const intentInContent = content["m.call.intent"];
|
||||
switch (intentInContent) {
|
||||
case "audio":
|
||||
return CallType.Voice;
|
||||
case "video":
|
||||
default:
|
||||
return CallType.Video;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all declined events that is related to the given rtc notification event.
|
||||
* @param event rtc notification event
|
||||
*/
|
||||
export function getDeclinedEvents(
|
||||
event: MatrixEvent,
|
||||
getRelationsForEvent?: GetRelationsForEvent,
|
||||
): MatrixEvent[] | null {
|
||||
const eventId = event.getId();
|
||||
if (eventId && getRelationsForEvent) {
|
||||
const relations = getRelationsForEvent(eventId, RelationType.Reference, EventType.RTCDecline)?.getRelations();
|
||||
if (relations) return relations;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { it, describe, expect, vi } from "vitest";
|
||||
import { CallDirection, CallType } from "@element-hq/web-shared-components";
|
||||
import { EventType, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getMockedRtcDeclineEvent, getMockedRtcNotificationEvent } from "../../call-mocks";
|
||||
import { formatTime } from "../../../../../../../DateUtils";
|
||||
import { DmTombstoneCallTileViewModel } from "./DmTombstoneCallTileViewModel";
|
||||
import { stubClient } from "../../../../../../../../test/test-utils";
|
||||
|
||||
describe("DmTombstoneCallTileViewModel", () => {
|
||||
it("should compute correct state on decline", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000, "@alice:m.org");
|
||||
const declineEvent = getMockedRtcDeclineEvent(mxEvent, "@alice:m.org");
|
||||
const getRelationsForEvent = vi.fn();
|
||||
const cli = stubClient();
|
||||
const vm = new DmTombstoneCallTileViewModel({ mxEvent, getRelationsForEvent, cli });
|
||||
|
||||
// Without decline event, isCallDeclined = false
|
||||
expect(vm.getSnapshot().isCallDeclined).toStrictEqual(false);
|
||||
|
||||
// Decline event comes through
|
||||
getRelationsForEvent.mockReturnValue({
|
||||
getRelations: () => [declineEvent],
|
||||
});
|
||||
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", EventType.RTCDecline);
|
||||
|
||||
// Timestamp should be that of the decline event
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285416000)));
|
||||
// Call should be declined
|
||||
expect(vm.getSnapshot().isCallDeclined).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it("should compute voice intent in state", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365);
|
||||
|
||||
const getRelationsForEvent = vi.fn();
|
||||
const cli = stubClient();
|
||||
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
|
||||
const { type } = vm.getSnapshot();
|
||||
expect(type).toStrictEqual(CallType.Voice);
|
||||
});
|
||||
|
||||
it("should compute video intent in state", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365);
|
||||
const getRelationsForEvent = vi.fn();
|
||||
const cli = stubClient();
|
||||
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
|
||||
const { type } = vm.getSnapshot();
|
||||
expect(type).toStrictEqual(CallType.Video);
|
||||
});
|
||||
|
||||
describe("should compute callDirection", () => {
|
||||
it("for outgoing", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@alice:m.org");
|
||||
const getRelationsForEvent = vi.fn();
|
||||
const cli = stubClient();
|
||||
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
|
||||
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
|
||||
expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Outgoing);
|
||||
});
|
||||
|
||||
it("for incoming", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@bob:m.org");
|
||||
const getRelationsForEvent = vi.fn();
|
||||
const cli = stubClient();
|
||||
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
|
||||
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
|
||||
expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Incoming);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { CallDirection, type DmTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../../../../../../../settings/SettingsStore";
|
||||
import type { GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile";
|
||||
import { getTimeFromEvent } from "./common";
|
||||
import {
|
||||
RoomTombstoneCallTileViewModel,
|
||||
type RoomTombstoneCallTileViewModelProps,
|
||||
} from "./RoomTombstoneCallTileViewModel";
|
||||
import { getDeclinedEvents, getIntentFromEvent } from "../../common";
|
||||
|
||||
export interface DmTombstoneCallTileViewModelProps extends RoomTombstoneCallTileViewModelProps {
|
||||
/**
|
||||
* Helper to fetch related events from a given event.
|
||||
*/
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
/**
|
||||
* The {@link MatrixClient} object to access js-sdk API.
|
||||
*/
|
||||
cli: MatrixClient;
|
||||
}
|
||||
|
||||
function generateSnapshot(props: DmTombstoneCallTileViewModelProps): {
|
||||
snapshot: DmTombstoneCallTileViewSnapshot;
|
||||
declineEvent: MatrixEvent | null;
|
||||
} {
|
||||
const { mxEvent, getRelationsForEvent, cli } = props;
|
||||
const type = getIntentFromEvent(mxEvent);
|
||||
|
||||
// Find the mx-id of the user who started this call
|
||||
const startedUserId = mxEvent.getSender();
|
||||
if (!startedUserId) {
|
||||
throw new Error("RTCNotification event has no sender associated with it!");
|
||||
}
|
||||
const callDirection = cli.getUserId() === startedUserId ? CallDirection.Outgoing : CallDirection.Incoming;
|
||||
|
||||
const declineEvent = getDeclinedEvents(mxEvent, getRelationsForEvent)?.[0] ?? null;
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const timestamp = getTimeFromEvent(declineEvent ?? mxEvent, showTwelveHour);
|
||||
return { snapshot: { timestamp, type, callDirection, isCallDeclined: !!declineEvent }, declineEvent };
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for a tombstone call in a DM.
|
||||
*/
|
||||
export class DmTombstoneCallTileViewModel extends RoomTombstoneCallTileViewModel<
|
||||
DmTombstoneCallTileViewSnapshot,
|
||||
DmTombstoneCallTileViewModelProps
|
||||
> {
|
||||
/**
|
||||
* The decline event associated with this call, if any.
|
||||
*/
|
||||
private declineEvent: MatrixEvent | null;
|
||||
|
||||
public constructor(props: DmTombstoneCallTileViewModelProps) {
|
||||
const { snapshot, declineEvent } = generateSnapshot(props);
|
||||
super(props, snapshot);
|
||||
this.declineEvent = declineEvent;
|
||||
|
||||
// When a relation is added to the event, recompute the state.
|
||||
this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => {
|
||||
const { declineEvent, snapshot } = generateSnapshot(props);
|
||||
this.declineEvent = declineEvent;
|
||||
this.snapshot.set(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
protected getTimestamp(showTwelveHour: boolean): string {
|
||||
return getTimeFromEvent(this.declineEvent ?? this.props.mxEvent, showTwelveHour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { it, describe, expect, vi } from "vitest";
|
||||
|
||||
import { getMockedRtcNotificationEvent } from "../../call-mocks";
|
||||
import { RoomTombstoneCallTileViewModel } from "./RoomTombstoneCallTileViewModel";
|
||||
import SettingsStore from "../../../../../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../../../settings/SettingLevel";
|
||||
import { formatTime } from "../../../../../../../DateUtils";
|
||||
|
||||
describe("RoomTombstoneCallTileViewModel", () => {
|
||||
it("should compute timestamp correctly", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
|
||||
const vm = new RoomTombstoneCallTileViewModel({ mxEvent });
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000)));
|
||||
});
|
||||
|
||||
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 RoomTombstoneCallTileViewModel({ mxEvent });
|
||||
const { timestamp } = vm.getSnapshot();
|
||||
|
||||
expect(timestamp).toStrictEqual(formatTime(new Date(924285348000), true));
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should change timestamp format when setting is modified", async () => {
|
||||
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, false);
|
||||
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
|
||||
const vm = new RoomTombstoneCallTileViewModel({ mxEvent });
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000)));
|
||||
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true);
|
||||
await vi.waitFor(() => {
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000), true));
|
||||
});
|
||||
SettingsStore.reset();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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, type RoomTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../../../../../../../settings/SettingsStore";
|
||||
import defaultDispatcher from "../../../../../../../dispatcher/dispatcher";
|
||||
import type { SettingUpdatedPayload } from "../../../../../../../dispatcher/payloads/SettingUpdatedPayload";
|
||||
import type { ActionPayload } from "../../../../../../../dispatcher/payloads";
|
||||
import { Action } from "../../../../../../../dispatcher/actions";
|
||||
import type { GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile";
|
||||
import { getTimeFromEvent } from "./common";
|
||||
|
||||
export interface RoomTombstoneCallTileViewModelProps {
|
||||
/**
|
||||
* Event of type `org.matrix.msc4075.rtc.notification`.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
/**
|
||||
* Helper to fetch related events from a given event.
|
||||
*/
|
||||
getRelationsForEvent?: GetRelationsForEvent;
|
||||
}
|
||||
|
||||
function generateSnapshot(event: MatrixEvent): RoomTombstoneCallTileViewSnapshot {
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const timestamp = getTimeFromEvent(event, showTwelveHour);
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload {
|
||||
return payload.action === Action.SettingUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for a tombstone call in a room.
|
||||
*/
|
||||
export class RoomTombstoneCallTileViewModel<
|
||||
T extends RoomTombstoneCallTileViewSnapshot = RoomTombstoneCallTileViewSnapshot,
|
||||
P extends RoomTombstoneCallTileViewModelProps = RoomTombstoneCallTileViewModelProps,
|
||||
> extends BaseViewModel<T, P> {
|
||||
public constructor(props: P, extraSnapshot: Partial<T> = {}) {
|
||||
const snapshot = { ...generateSnapshot(props.mxEvent), ...extraSnapshot };
|
||||
super(props, snapshot as T);
|
||||
|
||||
// Listen to the changes on settings so that we can update the timestamp format (12H vs 24H).
|
||||
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 = this.getTimestamp(showTwelveHour);
|
||||
this.snapshot.merge({ timestamp } as Partial<T>);
|
||||
};
|
||||
|
||||
protected getTimestamp(showTwelveHour: boolean): string {
|
||||
return getTimeFromEvent(this.props.mxEvent, showTwelveHour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
import { formatTime } from "../../../../../../../DateUtils";
|
||||
|
||||
function getTs(event: MatrixEvent): number {
|
||||
if (event.getType() === EventType.RTCNotification) {
|
||||
/**
|
||||
* According to the spec:
|
||||
* Receivers SHOULD use origin_server_ts if |sender_ts - origin_server_ts| > 20000 ms.
|
||||
*/
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
const senderTs = content["sender_ts"];
|
||||
const originServerTs = event.getTs();
|
||||
const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs;
|
||||
return ts;
|
||||
} else return event.getTs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time at which a call took place from a given rtc notification event.
|
||||
* @param event The notification event
|
||||
* @param showTwelveHour Whether the time is to be shown in 12 hour format
|
||||
* @returns A formatted time string
|
||||
*/
|
||||
export function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): string {
|
||||
const ts = getTs(event);
|
||||
const date = new Date(ts);
|
||||
const timestamp = formatTime(date, showTwelveHour);
|
||||
return timestamp;
|
||||
}
|
||||
@@ -1,145 +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 { EventType, type MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "@element-hq/web-shared-components";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { mkEvent, stubClient } from "../../test-utils";
|
||||
import { CallTileViewModel } from "../../../src/viewmodels/room/timeline/event-tile/call/CallTileViewModel";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getMockedRtcDeclineEvent(rtcNotificationEvent: MatrixEvent, sender = "@foo:m.org"): MatrixEvent {
|
||||
const mockEvent = mkEvent({
|
||||
type: EventType.RTCDecline,
|
||||
user: sender,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: rtcNotificationEvent.getId(),
|
||||
},
|
||||
},
|
||||
ts: 924285416000,
|
||||
event: true,
|
||||
});
|
||||
return mockEvent;
|
||||
}
|
||||
|
||||
describe("CallTileViewModel", () => {
|
||||
it("should set voice intent in state", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365);
|
||||
const vm = new CallTileViewModel({ 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 CallTileViewModel({ 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 CallTileViewModel({ 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 CallTileViewModel({ 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 CallTileViewModel({ 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");
|
||||
});
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
describe("On call declined", () => {
|
||||
it("should calculate isCallDeclined correctly", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365);
|
||||
// When there's no decline event, isCallDeclined = false
|
||||
const vm1 = new CallTileViewModel({ mxEvent, getRelationsForEvent: jest.fn() });
|
||||
expect(vm1.isCallDeclined).toStrictEqual(false);
|
||||
|
||||
// When there's a decline event, isCallDeclined = true
|
||||
const declineEvent = getMockedRtcDeclineEvent(mxEvent);
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue({
|
||||
getRelations: () => [declineEvent],
|
||||
});
|
||||
const vm2 = new CallTileViewModel({ mxEvent, getRelationsForEvent });
|
||||
expect(vm2.isCallDeclined).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it("should calculate isCallDeclinedByUs correctly", () => {
|
||||
const cli = stubClient();
|
||||
cli.getUserId = jest.fn().mockReturnValue("@bar:m.org");
|
||||
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 924285348000, 924285348000);
|
||||
const declineEvent: MatrixEvent[] = [];
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue({
|
||||
getRelations: () => declineEvent,
|
||||
});
|
||||
|
||||
// Decline event sent by somebody else
|
||||
declineEvent.push(getMockedRtcDeclineEvent(mxEvent));
|
||||
const vm = new CallTileViewModel({ mxEvent, getRelationsForEvent });
|
||||
expect(vm.getSnapshot().isCallDeclinedByUs).toStrictEqual(false);
|
||||
|
||||
// Decline event sent by us
|
||||
declineEvent.pop();
|
||||
declineEvent.push(getMockedRtcDeclineEvent(mxEvent, MatrixClientPeg.get()!.getUserId()!));
|
||||
const vm2 = new CallTileViewModel({ mxEvent, getRelationsForEvent });
|
||||
expect(vm2.getSnapshot().isCallDeclinedByUs).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it("should recompute state when call is declined", () => {
|
||||
const mxEvent = getMockedRtcNotificationEvent("audio", 924285348000, 924285348000);
|
||||
const declineEvent: MatrixEvent[] = [];
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue({
|
||||
getRelations: () => declineEvent,
|
||||
});
|
||||
|
||||
// No decline event yet, so timestamp should be based on rtc notification event.
|
||||
const vm = new CallTileViewModel({ mxEvent, getRelationsForEvent });
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual("17:55");
|
||||
|
||||
// Decline event arrives, timestamp should update to be that of the decline event.
|
||||
declineEvent.push(getMockedRtcDeclineEvent(mxEvent));
|
||||
mxEvent.emit(MatrixEventEvent.RelationsCreated, RelationType.Reference, EventType.RTCDecline);
|
||||
expect(vm.getSnapshot().timestamp).toStrictEqual("17:56");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ export default {
|
||||
"!scripts/**!",
|
||||
"!src/test/**!",
|
||||
"!recorder-worklet-loader.cjs!",
|
||||
"!src/**/*-{mock,mocks,snapshot,actions}.*!",
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// False positive
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||
`;
|
||||
@@ -58,6 +58,8 @@ export default defineConfig({
|
||||
"**/*.{stories,test}.{ts,tsx}",
|
||||
// Exclude test utilities
|
||||
"**/src/test/**",
|
||||
// Exclude mocks
|
||||
"**/src/**/*-{mock,mocks}.{ts,tsx}",
|
||||
// Exclude type definition files
|
||||
"**/*.d.ts",
|
||||
// Exclude playwright-common as it is just test utilities
|
||||
|
||||