Merge remote-tracking branch 'origin/develop' into hs/refactor-upload-logic+upload-module-api

This commit is contained in:
Half-Shot 2026-05-12 13:48:47 +01:00
commit b25d05a247
66 changed files with 2599 additions and 376 deletions

View File

@ -74,6 +74,7 @@
"html-entities": "^2.0.0",
"html-react-parser": "^6.0.0",
"is-ip": "^5.0.0",
"jest-silent-reporter": "^0.6.0",
"js-xxhash": "^5.0.0",
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",

View File

@ -718,7 +718,7 @@ test.describe("Timeline", () => {
await viewSourceEventExpanded.hover();
const toggleEventButton = viewSourceEventExpanded.getByRole("button", { name: "toggle event" });
// Check size and position of toggle on expanded view source event
// See: _ViewSourceEvent.pcss
// See: ViewSourceEventView.module.css
await expect(toggleEventButton).toHaveCSS("height", "16px"); // --ViewSourceEvent_toggle-size
await expect(toggleEventButton).toHaveCSS("align-self", "flex-end");
// Click again to collapse the source

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -226,17 +226,14 @@
@import "./views/messages/_MFileBody.pcss";
@import "./views/messages/_MImageBody.pcss";
@import "./views/messages/_MImageReplyBody.pcss";
@import "./views/messages/_MJitsiWidgetEvent.pcss";
@import "./views/messages/_MLocationBody.pcss";
@import "./views/messages/_MPollBody.pcss";
@import "./views/messages/_MStickerBody.pcss";
@import "./views/messages/_MediaBody.pcss";
@import "./views/messages/_MessageActionBar.pcss";
@import "./views/messages/_ReactionsRow.pcss";
@import "./views/messages/_RoomAvatarEvent.pcss";
@import "./views/messages/_TextualEvent.pcss";
@import "./views/messages/_ThreadActionBar.pcss";
@import "./views/messages/_ViewSourceEvent.pcss";
@import "./views/messages/_common_CryptoEvent.pcss";
@import "./views/polls/pollHistory/_PollHistory.pcss";
@import "./views/polls/pollHistory/_PollHistoryList.pcss";

View File

@ -1,13 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
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.
*/
.mx_EventTileBubble.mx_MJitsiWidgetEvent {
svg {
color: $header-panel-text-primary-color; /* XXX: Variable abuse */
}
}

View File

@ -1,50 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
.mx_EventTile_content.mx_ViewSourceEvent {
display: flex;
opacity: 0.6;
font-size: $font-12px;
width: 100%;
overflow-x: auto; /* Cancel overflow setting of .mx_EventTile_content */
line-height: normal; /* Align with avatar and E2E icon */
pre,
code {
flex: 1;
}
pre {
line-height: 1.2;
margin: 3.5px 0;
}
.mx_ViewSourceEvent_toggle {
--ViewSourceEvent_toggle-size: 16px;
visibility: hidden;
/* icon */
width: var(--ViewSourceEvent_toggle-size);
min-width: var(--ViewSourceEvent_toggle-size);
svg {
color: $accent;
width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.mx_EventTile:hover & {
visibility: visible;
}
}
&.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle {
align-self: flex-end;
height: var(--ViewSourceEvent_toggle-size);
}
}

View File

@ -1,4 +1,5 @@
/*
Copyright (C) 2026 Element Creations Ltd
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
@ -6,63 +7,62 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type RefObject } from "react";
import { useEffect, useRef, type RefObject } from "react";
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
import UIStore from "../../../stores/UIStore";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
interface IProps {
/**
* Element to watch for resize changes on.
*/
sensor: RefObject<Element | null>;
breakpoint: number;
onMeasurement(narrow: boolean): void;
/**
* Minimum width of element to be considered full-size.
* Defaults to `500px`
*/
breakpoint?: number;
/**
* Callback for when the narrowness property changes.
* @param narrow
* @returns
*/
onMeasurement: (narrow: boolean) => void;
}
export default class Measured extends React.PureComponent<IProps> {
private static instanceCount = 0;
private readonly instanceId: number;
let instanceCount = 0;
public static defaultProps = {
breakpoint: 500,
};
/**
* This component can watch a single element for width changes, and will fire
* a callback if the width changes to be lower or higher than the `breakpoint`.
*/
export default function Measured({ sensor, breakpoint = 500, onMeasurement }: IProps): null {
const instanceIdRef = useRef(instanceCount++);
const instanceId = instanceIdRef.current;
public constructor(props: IProps) {
super(props);
this.instanceId = Measured.instanceCount++;
}
public componentDidMount(): void {
console.log("Measured componentDidMount", this.props.sensor.current);
if (this.props.sensor.current) {
UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, this.props.sensor.current);
useEffect(() => {
if (sensor.current) {
UIStore.instance.trackElementDimensions(`Measured${instanceId}`, sensor.current);
}
UIStore.instance.on(`Measured${this.instanceId}`, this.onResize);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
const previous = prevProps.sensor.current;
const current = this.props.sensor.current;
console.log("Measured componentDidUpdate", current);
if (previous === current) return;
if (previous) {
UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`);
}
if (current) {
console.log("Measured trackElementDimensions");
UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, current);
}
}
return () => {
UIStore.instance.stopTrackingElementDimensions(`Measured${instanceId}`);
};
}, [sensor, instanceId]);
public componentWillUnmount(): void {
UIStore.instance.off(`Measured${this.instanceId}`, this.onResize);
UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`);
}
const narrow = useEventEmitterState<boolean>(
UIStore.instance,
`Measured${instanceId}`,
(_type: unknown, entry: ResizeObserverEntry) => {
// N.B there is only one `_type` of resize event.
return entry?.contentRect.width <= breakpoint;
},
);
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => {
if (type !== UI_EVENTS.Resize) return;
this.props.onMeasurement(entry.contentRect.width <= this.props.breakpoint);
};
// Only fire when the state changes.
useEffect(() => {
onMeasurement(narrow);
}, [onMeasurement, narrow]);
public render(): React.ReactNode {
return null;
}
return null;
}

View File

@ -1,78 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { EventTileBubble } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
timestamp?: JSX.Element;
}
export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const url = this.props.mxEvent.getContent()["url"];
const prevUrl = this.props.mxEvent.getPrevContent()["url"];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
if (!room) return null;
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find((w) => w.id === widgetId);
let joinCopy: string | null = _t("timeline|m.widget|jitsi_join_top_prompt");
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) {
joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt");
} else if (!widget) {
joinCopy = null;
}
if (!url) {
// removed
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_ended", { senderName })}
>
{this.props.timestamp}
</EventTileBubble>
);
} else if (prevUrl) {
// modified
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_updated", { senderName })}
subtitle={joinCopy}
>
{this.props.timestamp}
</EventTileBubble>
);
} else {
// assume added
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_started", { senderName })}
subtitle={joinCopy}
>
{this.props.timestamp}
</EventTileBubble>
);
}
}
}

View File

@ -1,81 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector 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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton";
import { mediaFromMxc } from "../../../customisations/Media";
import RoomAvatar from "../avatars/RoomAvatar";
import ImageView from "../elements/ImageView";
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
}
export default class RoomAvatarEvent extends React.Component<IProps> {
private onAvatarClick = (): void => {
const cli = MatrixClientPeg.safeGet();
const ev = this.props.mxEvent;
const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp;
if (!httpUrl) return;
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const text = _t("timeline|m.room.avatar|lightbox_title", {
senderDisplayName: ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(),
roomName: room ? room.name : "",
});
const params = {
src: httpUrl,
name: text,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
};
public render(): React.ReactNode {
const ev = this.props.mxEvent;
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().url || ev.getContent().url.trim().length === 0) {
return <div className="mx_TextualEvent">{_t("timeline|m.room.avatar|removed", { senderDisplayName })}</div>;
}
const room = MatrixClientPeg.safeGet().getRoom(ev.getRoomId());
// Provide all arguments to RoomAvatar via oobData because the avatar is historic
const oobData = {
avatarUrl: ev.getContent().url,
name: room ? room.name : "",
};
return (
<>
{_t(
"timeline|m.room.avatar|changed_img",
{ senderDisplayName: senderDisplayName },
{
img: () => (
<AccessibleButton
key="avatar"
className="mx_RoomAvatarEvent_avatar"
onClick={this.onAvatarClick}
>
<RoomAvatar room={room ?? undefined} size="14px" oobData={oobData} />
</AccessibleButton>
),
},
)}
</>
);
}
}

View File

@ -1,83 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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 MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
expanded: boolean;
}
export default class ViewSourceEvent extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
expanded: false,
};
}
public componentDidMount(): void {
const { mxEvent } = this.props;
const client = MatrixClientPeg.safeGet();
client.decryptEventIfNeeded(mxEvent);
if (mxEvent.isBeingDecrypted()) {
mxEvent.once(MatrixEventEvent.Decrypted, () => this.forceUpdate());
}
}
private onToggle = (ev: ButtonEvent): void => {
ev.preventDefault();
const { expanded } = this.state;
this.setState({
expanded: !expanded,
});
};
public render(): React.ReactNode {
const { mxEvent } = this.props;
const { expanded } = this.state;
let content;
if (expanded) {
content = <pre>{JSON.stringify(mxEvent, null, 4)}</pre>;
} else {
content = <code>{`{ "type": ${mxEvent.getType()} }`}</code>;
}
const classes = classNames("mx_ViewSourceEvent mx_EventTile_content", {
mx_ViewSourceEvent_expanded: expanded,
});
return (
<span className={classes}>
{content}
<AccessibleButton
kind="link"
title={_t("devtools|toggle_event")}
className="mx_ViewSourceEvent_toggle"
onClick={this.onToggle}
>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</AccessibleButton>
</span>
);
}
}

View File

@ -717,6 +717,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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 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
@ -1119,7 +1122,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} else if (
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
eventType === EventType.CallInvite ||
ElementCallEventType.matches(eventType)
ElementCallEventType.matches(eventType) ||
eventType === EventType.RTCNotification
) {
// no avatar or sender profile for continuation messages and call tiles
avatarSize = null;
@ -1197,6 +1201,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const showTimestamp =
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.alwaysShowTimestamps ||
this.props.last ||

View File

@ -18,10 +18,14 @@ import {
M_POLL_START,
} from "matrix-js-sdk/src/matrix";
import {
CallStartedTileView,
EncryptionEventView,
HiddenBodyView,
MJitsiWidgetEventView,
MKeyVerificationRequestView,
RoomAvatarEventView,
TextualEventView,
ViewSourceEventView,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
@ -33,24 +37,26 @@ import MessageEvent from "../components/views/messages/MessageEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent";
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
import RoomAvatar from "../components/views/avatars/RoomAvatar";
import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore";
import { ALL_RULE_TYPES } from "../mjolnir/BanList";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { WidgetType } from "../widgets/WidgetType";
import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent";
import { hasText } from "../TextForEvent";
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import { ModuleApi } from "../modules/Api";
import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel";
import { MJitsiWidgetEventViewModel } from "../viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel";
import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel";
import { RoomAvatarEventViewModel } from "../viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel";
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
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 { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps extends Pick<
@ -122,9 +128,77 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
}
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
function ViewSourceEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new ViewSourceEventViewModel({ mxEvent, cli }));
useEffect(() => {
vm.setProps({ cli, mxEvent });
}, [cli, mxEvent, vm]);
return (
<ViewSourceEventView
vm={vm}
ref={ref}
className="mx_ViewSourceEvent mx_EventTile_content"
expandedClassName="mx_ViewSourceEvent_expanded"
/>
);
}
function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli }));
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
return <MJitsiWidgetEventView vm={vm} ref={ref} className="mx_EventTileBubble" />;
}
function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext() ?? MatrixClientPeg.safeGet();
const vm = useCreateAutoDisposedViewModel(() => new RoomAvatarEventViewModel({ mxEvent, cli }));
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
const roomId = mxEvent.getRoomId();
const room = roomId ? cli.getRoom(roomId) : null;
return (
<RoomAvatarEventView
vm={vm}
ref={ref}
renderAvatar={(snapshot) => (
<RoomAvatar
room={room ?? undefined}
size="14px"
oobData={{
avatarUrl: snapshot.avatarUrl,
name: snapshot.roomName,
}}
/>
)}
/>
);
}
const RoomAvatarEventFactory: Factory = (ref, props) => <RoomAvatarEventWrappedView 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()
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) => <ViewSourceEventWrappedView ref={ref} {...props} />;
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEventWrappedView ref={ref} {...props} />;
export const RoomCreateEventFactory: Factory = (_ref, props) => <RoomPredecessorTile {...props} />;
const EVENT_TILE_TYPES = new Map<string, Factory>([
@ -135,6 +209,7 @@ const EVENT_TILE_TYPES = new Map<string, Factory>([
[M_POLL_END.name, MessageEventFactory],
[M_POLL_END.altName, MessageEventFactory],
[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>([
@ -143,7 +218,7 @@ const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomCreate, RoomCreateEventFactory],
[EventType.RoomMember, TextualEventFactory],
[EventType.RoomName, TextualEventFactory],
[EventType.RoomAvatar, (ref, props) => <RoomAvatarEvent ref={ref} {...props} />],
[EventType.RoomAvatar, RoomAvatarEventFactory],
[EventType.RoomThirdPartyInvite, TextualEventFactory],
[EventType.RoomHistoryVisibility, TextualEventFactory],
[EventType.RoomTopic, TextualEventFactory],

View File

@ -892,7 +892,6 @@
"thread_root_id": "Thread Root ID: %(threadRootId)s",
"threads_timeline": "Threads timeline",
"title": "Developer tools",
"toggle_event": "toggle event",
"toolbox": "Toolbox",
"use_at_own_risk": "This UI does NOT check the types of the values. Use at your own risk.",
"user_avatar": "Avatar: %(avatar)s",
@ -3462,9 +3461,7 @@
"m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s",
"m.room.avatar": {
"changed": "%(senderDisplayName)s changed the room avatar.",
"changed_img": "%(senderDisplayName)s changed the room avatar to <img/>",
"lightbox_title": "%(senderDisplayName)s changed the avatar for %(roomName)s",
"removed": "%(senderDisplayName)s removed the room avatar."
"lightbox_title": "%(senderDisplayName)s changed the avatar for %(roomName)s"
},
"m.room.canonical_alias": {
"alt_added": {

View File

@ -35,6 +35,7 @@ const calcIsInfoMessage = (
eventType !== EventType.RoomMessageEncrypted &&
eventType !== EventType.Sticker &&
eventType !== EventType.RoomCreate &&
eventType !== EventType.RTCNotification &&
!M_POLL_START.matches(eventType) &&
!M_POLL_END.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
let isBubbleMessage =
eventType === EventType.RTCNotification ||
eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype?.startsWith("m.key.verification")) ||
eventType === EventType.RoomCreate ||

View File

@ -164,7 +164,7 @@ export class RoomUploadViewModel
public onUploadOptionSelected = (type: ComposerApiFileUploadOption["type"]): void => {
const fn = this.extraUploadSelectFns.get(type);
if (!fn) {
throw Error("Unexpectedly called onUploadOptionSelected with an unknown type");
throw new Error("Unexpectedly called onUploadOptionSelected with an unknown type");
}
fn(this.room.roomId, {
inReplyToEventId: this.replyToEvent?.getId(),
@ -186,7 +186,7 @@ export const RoomUploadContext = createContext<RoomUploadViewModel | null>(null)
export function useRoomUploadViewModel(): RoomUploadViewModel {
const ctx = useContext(RoomUploadContext);
if (!ctx) {
throw Error("RoomFileUploadProvider is not present");
throw new Error("RoomFileUploadProvider is not present");
}
return ctx;
}
@ -208,14 +208,14 @@ export function RoomUploadContextProvider({
const openFilePicker = useCallback((): void => {
if (!uploadInput.current) {
throw Error("Input not ready");
throw new Error("Input not ready");
}
uploadInput.current.click();
}, [uploadInput]);
const vm = useCreateAutoDisposedViewModel(() => {
if (!room) {
throw Error("RoomUploadContextProvider must have a room");
throw new Error("RoomUploadContextProvider must have a room");
}
return new RoomUploadViewModel(
room,

View File

@ -0,0 +1,154 @@
/*
* 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 JSX } from "react";
import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type MJitsiWidgetEventViewModel as MJitsiWidgetEventViewModelInterface,
type MJitsiWidgetEventViewSnapshot,
} from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
import WidgetStore, { type IApp } from "../../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import { WidgetLayoutStore } from "../../../../stores/widgets/WidgetLayoutStore";
export interface MJitsiWidgetEventViewModelProps {
/**
* Caller-provided client.
*/
cli: MatrixClient;
/**
* Jitsi widget state event to derive tile state from.
*/
mxEvent: MatrixEvent;
/**
* Optional timestamp element rendered in the tile footer slot.
*/
timestamp?: JSX.Element;
/**
* Widget store used to resolve the widget referenced by the state event.
*/
widgetStore?: WidgetStore;
/**
* Widget layout store used to resolve the current join prompt.
*/
widgetLayoutStore?: WidgetLayoutStore;
}
type InternalProps = Required<Pick<MJitsiWidgetEventViewModelProps, "widgetStore" | "widgetLayoutStore">> &
Omit<MJitsiWidgetEventViewModelProps, "widgetStore" | "widgetLayoutStore">;
/**
* ViewModel for Jitsi widget events.
*/
export class MJitsiWidgetEventViewModel
extends BaseViewModel<MJitsiWidgetEventViewSnapshot, InternalProps>
implements MJitsiWidgetEventViewModelInterface
{
public constructor(props: MJitsiWidgetEventViewModelProps) {
const internalProps = {
...props,
widgetStore: props.widgetStore ?? WidgetStore.instance,
widgetLayoutStore: props.widgetLayoutStore ?? WidgetLayoutStore.instance,
};
super(internalProps, MJitsiWidgetEventViewModel.computeSnapshot(internalProps));
this.trackStoreUpdates();
}
public setEvent(mxEvent: MatrixEvent): void {
this.props = { ...this.props, mxEvent };
this.updateSnapshotFromProps();
}
private trackStoreUpdates(): void {
const roomId = this.props.mxEvent.getRoomId();
const room = roomId ? this.props.cli.getRoom(roomId) : null;
this.disposables.trackListener(this.props.widgetStore, UPDATE_EVENT, (updatedRoomId?: unknown) => {
if (typeof updatedRoomId === "string" && updatedRoomId !== this.props.mxEvent.getRoomId()) return;
this.updateSnapshotFromProps();
});
if (roomId) {
this.disposables.trackListener(this.props.widgetStore, roomId, () => this.updateSnapshotFromProps());
}
if (room) {
this.disposables.trackListener(this.props.widgetLayoutStore, WidgetLayoutStore.emissionForRoom(room), () =>
this.updateSnapshotFromProps(),
);
}
}
private updateSnapshotFromProps(): void {
this.snapshot.merge(MJitsiWidgetEventViewModel.computeSnapshot(this.props));
}
private static computeSnapshot(props: InternalProps): MJitsiWidgetEventViewSnapshot {
const { mxEvent, timestamp } = props;
const roomId = mxEvent.getRoomId();
const room = roomId ? props.cli.getRoom(roomId) : null;
if (!room) {
return {
isVisible: false,
title: "",
subtitle: null,
timestamp,
};
}
const content = mxEvent.getContent<{ url?: string }>();
const prevContent = mxEvent.getPrevContent() as { url?: string };
const senderName = mxEvent.sender?.name || mxEvent.getSender() || "";
const widget = MJitsiWidgetEventViewModel.getWidget(props);
let subtitle: string | null = null;
if (content.url && widget) {
subtitle = props.widgetLayoutStore.isInContainer(room, widget, "right")
? _t("timeline|m.widget|jitsi_join_right_prompt")
: _t("timeline|m.widget|jitsi_join_top_prompt");
}
if (!content.url) {
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_ended", { senderName }),
subtitle: null,
timestamp,
};
}
if (prevContent.url) {
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_updated", { senderName }),
subtitle,
timestamp,
};
}
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_started", { senderName }),
subtitle,
timestamp,
};
}
private static getWidget(props: InternalProps): IApp | undefined {
const roomId = props.mxEvent.getRoomId();
const widgetId = props.mxEvent.getStateKey();
if (!roomId || widgetId === undefined) return undefined;
return props.widgetStore.getRoom(roomId, true).widgets.find((widget) => widget.id === widgetId);
}
}

View File

@ -0,0 +1,109 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types";
import {
BaseViewModel,
type RoomAvatarEventViewModel as RoomAvatarEventViewModelInterface,
type RoomAvatarEventViewSnapshot,
} from "@element-hq/web-shared-components";
import { mediaFromMxc } from "../../../../customisations/Media";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import ImageView from "../../../../components/views/elements/ImageView";
export interface RoomAvatarEventViewModelProps {
/**
* Caller-provided client.
*/
cli: MatrixClient;
/**
* Room avatar state event.
*/
mxEvent: MatrixEvent;
}
/**
* ViewModel for room avatar state events.
*/
export class RoomAvatarEventViewModel
extends BaseViewModel<RoomAvatarEventViewSnapshot, RoomAvatarEventViewModelProps>
implements RoomAvatarEventViewModelInterface
{
public constructor(props: RoomAvatarEventViewModelProps) {
super(props, RoomAvatarEventViewModel.computeSnapshot(props));
}
public setEvent(mxEvent: MatrixEvent): void {
this.props = { ...this.props, mxEvent };
this.updateSnapshotFromProps();
}
public onAvatarClick = (): void => {
const avatarUrl = RoomAvatarEventViewModel.getAvatarUrl(this.props.mxEvent);
if (!avatarUrl) return;
const httpUrl = mediaFromMxc(avatarUrl, this.props.cli).srcHttp;
if (!httpUrl) return;
Modal.createDialog(
ImageView,
{
src: httpUrl,
name: RoomAvatarEventViewModel.computeLightboxLabel(this.props),
},
"mx_Dialog_lightbox",
undefined,
true,
);
};
private updateSnapshotFromProps(): void {
this.snapshot.merge(RoomAvatarEventViewModel.computeSnapshot(this.props));
}
private static computeSnapshot(props: RoomAvatarEventViewModelProps): RoomAvatarEventViewSnapshot {
const avatarUrl = RoomAvatarEventViewModel.getAvatarUrl(props.mxEvent);
const senderDisplayName = RoomAvatarEventViewModel.getSenderDisplayName(props.mxEvent);
const roomName = RoomAvatarEventViewModel.getRoomName(props);
return {
senderDisplayName,
roomName,
avatarUrl,
lightboxLabel: RoomAvatarEventViewModel.computeLightboxLabel(props),
isRemoved: !avatarUrl,
};
}
private static computeLightboxLabel(props: RoomAvatarEventViewModelProps): string {
return _t("timeline|m.room.avatar|lightbox_title", {
senderDisplayName: RoomAvatarEventViewModel.getSenderDisplayName(props.mxEvent),
roomName: RoomAvatarEventViewModel.getRoomName(props),
});
}
private static getSenderDisplayName(mxEvent: MatrixEvent): string {
return mxEvent.sender?.name || mxEvent.getSender() || "";
}
private static getRoomName({ cli, mxEvent }: RoomAvatarEventViewModelProps): string {
const roomId = mxEvent.getRoomId();
if (!roomId) return "";
return cli.getRoom(roomId)?.name ?? "";
}
private static getAvatarUrl(mxEvent: MatrixEvent): string | undefined {
const avatarUrl = mxEvent.getContent<RoomAvatarEventContent>().url;
if (!avatarUrl || avatarUrl.trim().length === 0) return undefined;
return avatarUrl;
}
}

View File

@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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 MouseEvent } from "react";
import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
Disposables,
type ViewSourceEventViewModel as ViewSourceEventViewModelInterface,
type ViewSourceEventViewSnapshot,
} from "@element-hq/web-shared-components";
export interface ViewSourceEventViewModelProps {
/**
* The hidden event whose source is being rendered.
*/
mxEvent: MatrixEvent;
/**
* Matrix client used to request decryption before rendering event source.
*/
cli: MatrixClient;
}
/**
* ViewModel for hidden event source rendering.
*/
export class ViewSourceEventViewModel
extends BaseViewModel<ViewSourceEventViewSnapshot, ViewSourceEventViewModelProps>
implements ViewSourceEventViewModelInterface
{
private decryptionListenerDisposables?: Disposables;
private static computeSnapshot(
{ mxEvent }: ViewSourceEventViewModelProps,
expanded: boolean,
): ViewSourceEventViewSnapshot {
return {
expanded,
preview: `{ "type": ${mxEvent.getType()} }`,
source: expanded ? ViewSourceEventViewModel.computeSource(mxEvent) : "",
};
}
private static computeSource(mxEvent: MatrixEvent): string {
return JSON.stringify(mxEvent, null, 4) ?? "";
}
public constructor(props: ViewSourceEventViewModelProps) {
super(props, ViewSourceEventViewModel.computeSnapshot(props, false));
this.disposables.track(() => this.removeDecryptionListener());
this.setupDecryptionListener();
}
public setProps(newProps: Partial<ViewSourceEventViewModelProps>): void {
const nextProps = { ...this.props, ...newProps };
const eventChanged = this.props.mxEvent !== nextProps.mxEvent;
const clientChanged = this.props.cli !== nextProps.cli;
if (!eventChanged && !clientChanged) return;
this.props = nextProps;
this.setupDecryptionListener();
if (eventChanged) {
this.updateSnapshotFromProps();
}
}
public onToggle = (event: MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
const expanded = !this.snapshot.current.expanded;
this.snapshot.merge({
expanded,
source: expanded ? ViewSourceEventViewModel.computeSource(this.props.mxEvent) : "",
});
};
private updateSnapshotFromProps(): void {
this.snapshot.merge(ViewSourceEventViewModel.computeSnapshot(this.props, this.snapshot.current.expanded));
}
private setupDecryptionListener(): void {
this.removeDecryptionListener();
const { cli, mxEvent } = this.props;
cli.decryptEventIfNeeded(mxEvent);
if (!mxEvent.isBeingDecrypted()) return;
const onDecrypted = (): void => {
this.removeDecryptionListener();
if (this.props.mxEvent !== mxEvent) return;
this.updateSnapshotFromProps();
};
this.decryptionListenerDisposables = new Disposables();
this.decryptionListenerDisposables.trackListener(mxEvent, MatrixEventEvent.Decrypted, onDecrypted);
}
private removeDecryptionListener(): void {
this.decryptionListenerDisposables?.dispose();
this.decryptionListenerDisposables = undefined;
}
}

View File

@ -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 });
};
}

View File

@ -23,6 +23,8 @@ import { createTestClient, mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { ModuleApi } from "../../../src/modules/Api";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
const roomId = "!room:example.com";
@ -41,6 +43,18 @@ function makeVerificationRequestEvent({ sender, to }: { sender: string; to: stri
});
}
function makeRoomAvatarEvent(url = "mxc://example.com/avatar"): MatrixEvent {
return new MatrixEvent({
type: EventType.RoomAvatar,
state_key: "",
room_id: roomId,
sender: "@alice:example.com",
content: {
url,
},
});
}
describe("pickFactory", () => {
let client: MatrixClient;
let room: Room;
@ -363,4 +377,40 @@ describe("renderTile", () => {
expect(() => render(tile)).toThrow("Attempting to render verification request without a client context!");
});
it("renders room avatar events with the wrapped shared-components view", () => {
const room = new Room(roomId, client, client.getSafeUserId());
room.name = "General";
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
room_id: room.roomId,
sender: client.getUserId()!,
content: {
creator: client.getUserId()!,
room_version: "9",
},
}),
]);
mocked(client.getRoom).mockReturnValue(room);
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn().mockReturnValue(null),
} as unknown as DMRoomMap);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
const roomAvatarEvent = makeRoomAvatarEvent();
roomAvatarEvent.sender = { name: "Alice" } as MatrixEvent["sender"];
const tile = renderTile(
TimelineRenderingType.Room,
{ mxEvent: roomAvatarEvent, showHiddenEvents: false },
client,
);
if (!tile) throw new Error("Expected a room avatar event tile");
render(React.createElement(MatrixClientContext.Provider, { value: client }, tile));
expect(screen.getByText("Alice changed the room avatar to")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Alice changed the avatar for General" })).toBeInTheDocument();
});
});

View File

@ -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");
});
});
});

View File

@ -0,0 +1,180 @@
/*
* 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 MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { MJitsiWidgetEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { mkEvent, stubClient } from "../../test-utils";
import type WidgetStore from "../../../src/stores/WidgetStore";
import type { IApp } from "../../../src/stores/WidgetStore";
describe("MJitsiWidgetEventViewModel", () => {
const roomId = "!room:example.com";
const widgetId = "jitsi";
let cli: MatrixClient;
let room: Room;
let widget: IApp;
let widgetStore: WidgetStore & EventEmitter;
let widgetLayoutStore: WidgetLayoutStore & EventEmitter;
const createEvent = (content: { url?: string }, prevContent: { url?: string } = {}) =>
mkEvent({
event: true,
room: roomId,
user: "@alice:example.com",
skey: widgetId,
type: "im.vector.modular.widgets",
content,
prev_content: prevContent,
});
const createVm = (
props: Partial<ConstructorParameters<typeof MJitsiWidgetEventViewModel>[0]> = {},
): MJitsiWidgetEventViewModel =>
new MJitsiWidgetEventViewModel({
cli,
mxEvent: createEvent({ url: "https://jitsi.example.com/room" }),
widgetStore,
widgetLayoutStore,
...props,
});
beforeEach(() => {
cli = stubClient();
room = cli.getRoom(roomId)!;
widget = {
id: widgetId,
roomId,
type: "m.jitsi",
} as IApp;
widgetStore = Object.assign(new EventEmitter(), {
getRoom: jest.fn().mockReturnValue({ widgets: [widget] }),
}) as unknown as WidgetStore & EventEmitter;
widgetLayoutStore = Object.assign(new EventEmitter(), {
isInContainer: jest.fn().mockReturnValue(false),
}) as unknown as WidgetLayoutStore & EventEmitter;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders a started Jitsi event with the top join prompt", () => {
const vm = createVm();
expect(vm.getSnapshot()).toMatchObject({
isVisible: true,
title: "Video conference started by @alice:example.com",
subtitle: "Join the conference at the top of this room",
});
});
it("uses the right-panel join prompt when the widget is in the right container", () => {
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
const vm = createVm();
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
});
it("omits the join prompt when the widget no longer exists", () => {
jest.mocked(widgetStore.getRoom).mockReturnValue({ widgets: [] });
const vm = createVm();
expect(vm.getSnapshot()).toMatchObject({
isVisible: true,
title: "Video conference started by @alice:example.com",
subtitle: null,
});
});
it("renders an updated Jitsi event", () => {
const vm = createVm({
mxEvent: createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" }),
});
expect(vm.getSnapshot()).toMatchObject({
isVisible: true,
title: "Video conference updated by @alice:example.com",
subtitle: "Join the conference at the top of this room",
});
});
it("renders an ended Jitsi event without a join prompt", () => {
const vm = createVm({
mxEvent: createEvent({}, { url: "https://old.example.com/room" }),
});
expect(vm.getSnapshot()).toMatchObject({
isVisible: true,
title: "Video conference ended by @alice:example.com",
subtitle: null,
});
});
it("hides the event when the room is unavailable", () => {
jest.spyOn(cli, "getRoom").mockReturnValue(null);
const vm = createVm();
expect(vm.getSnapshot()).toMatchObject({
isVisible: false,
title: "",
subtitle: null,
});
});
it("updates the snapshot when the event changes", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" }));
expect(vm.getSnapshot().title).toBe("Video conference updated by @alice:example.com");
expect(listener).toHaveBeenCalledTimes(1);
});
it("does not emit updates when setEvent receives the current event", () => {
const mxEvent = createEvent({ url: "https://jitsi.example.com/room" });
const listener = jest.fn();
const vm = createVm({ mxEvent });
vm.subscribe(listener);
vm.setEvent(mxEvent);
expect(listener).not.toHaveBeenCalled();
});
it("updates when widget stores emit for the room", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
widgetStore.emit(UPDATE_EVENT, roomId);
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
expect(listener).toHaveBeenCalledTimes(1);
});
it("updates when the widget layout store emits for the room", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
widgetLayoutStore.emit(WidgetLayoutStore.emissionForRoom(room));
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
expect(listener).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,138 @@
/*
* 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 MatrixClient, MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import Modal from "../../../src/Modal";
import { RoomAvatarEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel";
describe("RoomAvatarEventViewModel", () => {
const roomId = "!room:example.org";
let cli: MatrixClient;
let room: Room;
let mxcUrlToHttp: jest.Mock;
beforeEach(() => {
mxcUrlToHttp = jest.fn().mockReturnValue("https://example.org/_matrix/media/v3/download/avatar");
room = {
name: "General",
} as unknown as Room;
cli = {
getRoom: jest.fn().mockReturnValue(room),
mxcUrlToHttp,
} as unknown as MatrixClient;
});
afterEach(() => {
jest.restoreAllMocks();
});
const createEvent = (
url?: string,
sender = "@alice:example.org",
eventRoomId: string | undefined = roomId,
): MatrixEvent =>
new MatrixEvent({
type: EventType.RoomAvatar,
room_id: eventRoomId,
state_key: "",
sender,
content: {
url,
},
});
it("extracts room avatar event details", () => {
const mxEvent = createEvent("mxc://example.org/avatar");
mxEvent.sender = { name: "Alice" } as MatrixEvent["sender"];
const vm = new RoomAvatarEventViewModel({ cli, mxEvent });
expect(vm.getSnapshot()).toEqual({
senderDisplayName: "Alice",
roomName: "General",
avatarUrl: "mxc://example.org/avatar",
lightboxLabel: "Alice changed the avatar for General",
isRemoved: false,
});
});
it("falls back to the sender ID when no sender member is available", () => {
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") });
expect(vm.getSnapshot().senderDisplayName).toBe("@alice:example.org");
});
it("marks the event as removed when no avatar URL is present", () => {
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("") });
expect(vm.getSnapshot()).toMatchObject({
avatarUrl: undefined,
isRemoved: true,
});
});
it("updates the snapshot when the event changes", () => {
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") });
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(createEvent("mxc://example.org/next", "@bob:example.org"));
expect(vm.getSnapshot()).toMatchObject({
senderDisplayName: "@bob:example.org",
avatarUrl: "mxc://example.org/next",
isRemoved: false,
});
expect(listener).toHaveBeenCalledTimes(1);
});
it("does not emit when the event-derived snapshot is unchanged", () => {
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") });
const listener = jest.fn();
vm.subscribe(listener);
vm.setEvent(createEvent("mxc://example.org/avatar"));
expect(listener).not.toHaveBeenCalled();
});
it("opens the room avatar in the lightbox", () => {
const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any);
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") });
vm.onAvatarClick();
expect(mxcUrlToHttp).toHaveBeenCalledWith(
"mxc://example.org/avatar",
undefined,
undefined,
undefined,
false,
true,
);
expect(dialogSpy).toHaveBeenCalledWith(
expect.any(Function),
{
src: "https://example.org/_matrix/media/v3/download/avatar",
name: "@alice:example.org changed the avatar for General",
},
"mx_Dialog_lightbox",
undefined,
true,
);
});
it("does not open the lightbox when the event has no avatar URL", () => {
const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any);
const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("") });
vm.onAvatarClick();
expect(dialogSpy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,143 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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 MouseEvent } from "react";
import { type MatrixClient, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { ViewSourceEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
describe("ViewSourceEventViewModel", () => {
const createClient = (): MatrixClient =>
({
decryptEventIfNeeded: jest.fn().mockResolvedValue(undefined),
}) as unknown as MatrixClient;
const createEvent = (type = "m.room.message", content: Record<string, unknown> = {}): MatrixEvent =>
new MatrixEvent({
type,
event_id: "$event:example.org",
sender: "@alice:example.org",
content,
});
const createClickEvent = (): MouseEvent<HTMLButtonElement> =>
({
preventDefault: jest.fn(),
}) as unknown as MouseEvent<HTMLButtonElement>;
it("creates a collapsed event source snapshot and requests decryption", () => {
const cli = createClient();
const mxEvent = createEvent("m.room.member");
const vm = new ViewSourceEventViewModel({ cli, mxEvent });
expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
expect(vm.getSnapshot()).toEqual({
expanded: false,
preview: '{ "type": m.room.member }',
source: "",
});
});
it("toggles expanded state", () => {
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
const event = createClickEvent();
vm.onToggle(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(vm.getSnapshot().expanded).toBe(true);
expect(vm.getSnapshot().source).toBe(JSON.stringify(mxEvent, null, 4));
vm.onToggle(createClickEvent());
expect(vm.getSnapshot().expanded).toBe(false);
expect(vm.getSnapshot().source).toBe("");
});
it("updates the event source when the event changes", () => {
const cli = createClient();
const oldEvent = createEvent("m.room.message");
const newEvent = createEvent("m.room.topic", { topic: "New topic" });
const vm = new ViewSourceEventViewModel({ cli, mxEvent: oldEvent });
vm.onToggle(createClickEvent());
vm.setProps({ mxEvent: newEvent });
expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(newEvent);
expect(vm.getSnapshot()).toEqual({
expanded: true,
preview: '{ "type": m.room.topic }',
source: JSON.stringify(newEvent, null, 4),
});
});
it("removes the previous decryption listener when the event changes", () => {
const oldEvent = createEvent("m.room.encrypted");
jest.spyOn(oldEvent, "isBeingDecrypted").mockReturnValue(true);
const offSpy = jest.spyOn(oldEvent, "off");
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent: oldEvent });
vm.setProps({ mxEvent: createEvent("m.room.message") });
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
});
it("updates the decryption request when the client changes", () => {
const oldClient = createClient();
const newClient = createClient();
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli: oldClient, mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setProps({ cli: newClient });
expect(newClient.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit when setProps receives unchanged props", () => {
const cli = createClient();
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli, mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setProps({ cli, mxEvent });
expect(listener).not.toHaveBeenCalled();
});
it("updates the source after decryption completes", () => {
const mxEvent = createEvent("m.room.encrypted", { ciphertext: "encrypted" });
jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true);
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
vm.onToggle(createClickEvent());
const listener = jest.fn();
vm.subscribe(listener);
mxEvent.getContent().body = "decrypted";
mxEvent.emit(MatrixEventEvent.Decrypted, mxEvent);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().source).toContain("decrypted");
});
it("removes decryption listeners on dispose", () => {
const mxEvent = createEvent("m.room.encrypted");
jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true);
const offSpy = jest.spyOn(mxEvent, "off");
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
vm.dispose();
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
});
});

View File

@ -41,6 +41,9 @@
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"
},
"devtools": {
"toggle_event": "toggle event"
},
"keyboard": {
"shift": "Shift"
},
@ -199,6 +202,10 @@
"n_minutes_ago": "%(num)s minutes ago"
},
"timeline": {
"call_tile": {
"video_call_title": "Video call",
"voice_call_title": "Voice call"
},
"decryption_failure": {
"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",
@ -218,6 +225,10 @@
"m.file": {
"error_invalid": "Invalid file"
},
"m.room.avatar": {
"changed_img": "%(senderDisplayName)s changed the room avatar to<img/>",
"removed": "%(senderDisplayName)s removed the room avatar."
},
"m.room.encryption": {
"disable_attempt": "Ignored attempt to disable encryption",
"disabled": "Encryption not enabled",

View File

@ -28,6 +28,7 @@ export * from "./room/timeline/event-tile/body/MjolnirBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./room/timeline/event-tile/body/TextualBodyView";
export * from "./room/timeline/event-tile/body/UnknownBodyView";
export * from "./room/timeline/event-tile/body/ViewSourceEventView";
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
export * from "./core/pill-input/Pill";
export * from "./core/pill-input/PillInput";
@ -40,9 +41,12 @@ export * from "./room/timeline/TimelineSeparator";
export * from "./room/timeline/event-tile/actions/ActionBarView";
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
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/MJitsiWidgetEventView";
export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView";
export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge";
export * from "./room/timeline/event-tile/EventTileView/RoomAvatarEventView";
export * from "./room/timeline/event-tile/EventTileView/TextualEventView";
export * from "./room/timeline/event-tile/body/AudioPlayerView";
export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView";

View File

@ -0,0 +1,73 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { MJitsiWidgetEventView, type MJitsiWidgetEventViewSnapshot } from "./MJitsiWidgetEventView";
type MJitsiWidgetEventViewProps = MJitsiWidgetEventViewSnapshot & {
className?: string;
};
const MJitsiWidgetEventViewWrapperImpl = ({
className,
...snapshot
}: MJitsiWidgetEventViewProps): JSX.Element | null => {
const vm = useMockedViewModel(snapshot, {});
return <MJitsiWidgetEventView vm={vm} className={className} />;
};
const MJitsiWidgetEventViewWrapper = withViewDocs(MJitsiWidgetEventViewWrapperImpl, MJitsiWidgetEventView);
const meta = {
title: "Timeline/Timeline Event/MJitsiWidgetEventView",
component: MJitsiWidgetEventViewWrapper,
tags: ["autodocs"],
args: {
isVisible: true,
title: "Video conference started by Alice",
subtitle: "Join the conference at the top of this room",
className: "",
},
} satisfies Meta<typeof MJitsiWidgetEventViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Started: Story = {};
export const Updated: Story = {
args: {
title: "Video conference updated by Alice",
subtitle: "Join the conference from the room information card on the right",
},
};
export const Ended: Story = {
args: {
title: "Video conference ended by Alice",
subtitle: null,
},
};
export const Hidden: Story = {
args: {
isVisible: false,
title: "",
subtitle: null,
},
};
export const WithTimestamp: Story = {
args: {
timestamp: <span>14:56</span>,
},
};

View File

@ -0,0 +1,82 @@
/*
* 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 { render, screen } from "@test-utils";
import React from "react";
import { describe, expect, it } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import { MJitsiWidgetEventView } from "./MJitsiWidgetEventView";
import * as stories from "./MJitsiWidgetEventView.stories";
const { Started, Updated, Ended, Hidden, WithTimestamp } = composeStories(stories);
describe("MJitsiWidgetEventView", () => {
it("renders the Started story", () => {
const { container } = render(<Started />);
expect(container).toMatchSnapshot();
expect(screen.getByText("Video conference started by Alice")).toBeInTheDocument();
expect(screen.getByText("Join the conference at the top of this room")).toBeInTheDocument();
});
it("renders the Updated story", () => {
const { container } = render(<Updated />);
expect(container).toMatchSnapshot();
expect(screen.getByText("Video conference updated by Alice")).toBeInTheDocument();
expect(screen.getByText("Join the conference from the room information card on the right")).toBeInTheDocument();
});
it("renders the Ended story without a subtitle", () => {
const { container } = render(<Ended />);
expect(container).toMatchSnapshot();
expect(screen.getByText("Video conference ended by Alice")).toBeInTheDocument();
expect(screen.queryByText(/Join the conference/)).not.toBeInTheDocument();
});
it("renders nothing when hidden", () => {
const { container } = render(<Hidden />);
expect(container).toMatchSnapshot();
expect(screen.queryByText(/Video conference/)).not.toBeInTheDocument();
});
it("renders a timestamp", () => {
const { container } = render(<WithTimestamp />);
expect(container).toMatchSnapshot();
expect(screen.getByText("14:56")).toBeInTheDocument();
});
it("applies a custom className to the root element", () => {
const vm = new MockViewModel({
isVisible: true,
title: "Video conference started by Alice",
subtitle: null,
});
const { container } = render(<MJitsiWidgetEventView vm={vm} className="custom-jitsi" />);
expect(container.firstChild).toHaveClass("custom-jitsi");
});
it("forwards the provided ref to the root element", () => {
const ref = React.createRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>;
const vm = new MockViewModel({
isVisible: true,
title: "Video conference started by Alice",
subtitle: null,
});
render(<MJitsiWidgetEventView vm={vm} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current).toHaveTextContent("Video conference started by Alice");
});
});

View File

@ -0,0 +1,73 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { EventTileBubble } from "../EventTileBubble";
export interface MJitsiWidgetEventViewSnapshot {
/**
* Whether the event has enough context to render.
*/
isVisible: boolean;
/**
* Main title text for the Jitsi widget event.
*/
title: string;
/**
* Optional join prompt shown below the title.
*/
subtitle: string | null;
/**
* Optional timestamp element rendered in the EventTileBubble footer slot.
*/
timestamp?: JSX.Element;
}
export type MJitsiWidgetEventViewModel = ViewModel<MJitsiWidgetEventViewSnapshot>;
export interface MJitsiWidgetEventViewProps {
/**
* ViewModel providing the current Jitsi widget event snapshot.
*/
vm: MJitsiWidgetEventViewModel;
/**
* Optional CSS classes passed through to EventTileBubble.
*/
className?: string;
/**
* Optional Ref forwarded to the root DOM element.
*/
ref?: React.RefObject<HTMLDivElement>;
}
/**
* Renders a timeline bubble describing a Jitsi widget state event.
*/
export function MJitsiWidgetEventView({
vm,
className,
ref,
}: Readonly<MJitsiWidgetEventViewProps>): JSX.Element | null {
const { isVisible, title, subtitle, timestamp } = useViewModel(vm);
if (!isVisible) return null;
return (
<EventTileBubble
icon={<VideoCallSolidIcon color="var(--cpd-color-text-primary)" />}
className={className}
title={title}
subtitle={subtitle || undefined}
ref={ref}
>
{timestamp}
</EventTileBubble>
);
}

View File

@ -0,0 +1,125 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MJitsiWidgetEventView > renders a timestamp 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
color="var(--cpd-color-text-primary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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="EventTileBubble-module_title"
>
Video conference started by Alice
</div>
<div
class="EventTileBubble-module_subtitle"
>
Join the conference at the top of this room
</div>
<span>
14:56
</span>
</div>
</div>
`;
exports[`MJitsiWidgetEventView > renders nothing when hidden 1`] = `<div />`;
exports[`MJitsiWidgetEventView > renders the Ended story without a subtitle 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
color="var(--cpd-color-text-primary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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="EventTileBubble-module_title"
>
Video conference ended by Alice
</div>
</div>
</div>
`;
exports[`MJitsiWidgetEventView > renders the Started story 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
color="var(--cpd-color-text-primary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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="EventTileBubble-module_title"
>
Video conference started by Alice
</div>
<div
class="EventTileBubble-module_subtitle"
>
Join the conference at the top of this room
</div>
</div>
</div>
`;
exports[`MJitsiWidgetEventView > renders the Updated story 1`] = `
<div>
<div
class="EventTileBubble-module_container"
>
<svg
color="var(--cpd-color-text-primary)"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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="EventTileBubble-module_title"
>
Video conference updated by Alice
</div>
<div
class="EventTileBubble-module_subtitle"
>
Join the conference from the room information card on the right
</div>
</div>
</div>
`;

View File

@ -0,0 +1,13 @@
/*
* 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 {
MJitsiWidgetEventView,
type MJitsiWidgetEventViewProps,
type MJitsiWidgetEventViewSnapshot,
type MJitsiWidgetEventViewModel,
} from "./MJitsiWidgetEventView";

View File

@ -0,0 +1,31 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.textualEvent {
font-size: var(--cpd-font-size-body-sm);
line-height: normal;
overflow-y: hidden;
color: var(--cpd-color-text-secondary);
}
.textualEvent[data-event-layout="irc"] {
padding: 1px 0;
display: inline-block;
line-height: 1.125rem;
}
.avatarButton {
display: inline;
position: relative;
top: 3px;
margin-inline-start: 0.25em;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
}

View File

@ -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 React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
RoomAvatarEventView,
type RoomAvatarEventViewActions,
type RoomAvatarEventViewSnapshot,
} from "./RoomAvatarEventView";
type RoomAvatarEventViewStoryProps = RoomAvatarEventViewSnapshot &
RoomAvatarEventViewActions & {
className?: string;
};
const RoomAvatarEventViewWrapperImpl = ({
onAvatarClick,
className,
...snapshot
}: RoomAvatarEventViewStoryProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onAvatarClick });
return (
<RoomAvatarEventView
vm={vm}
className={className}
renderAvatar={() => (
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 14,
height: 14,
borderRadius: "50%",
background: "#0dbd8b",
}}
/>
)}
/>
);
};
const RoomAvatarEventViewWrapper = withViewDocs(RoomAvatarEventViewWrapperImpl, RoomAvatarEventView);
const meta = {
title: "Timeline/Timeline Event/RoomAvatarEventView",
component: RoomAvatarEventViewWrapper,
tags: ["autodocs"],
args: {
senderDisplayName: "Alice",
roomName: "General",
avatarUrl: "mxc://example.org/avatar",
lightboxLabel: "Alice changed the avatar for General",
isRemoved: false,
onAvatarClick: fn(),
className: "",
},
} satisfies Meta<typeof RoomAvatarEventViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Changed: Story = {};
export const Removed: Story = {
args: {
avatarUrl: undefined,
isRemoved: true,
},
};

View File

@ -0,0 +1,27 @@
/*
* 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 { render } from "@test-utils";
import React from "react";
import { describe, expect, it } from "vitest";
import * as stories from "./RoomAvatarEventView.stories.tsx";
const { Changed, Removed } = composeStories(stories);
describe("RoomAvatarEventView", () => {
it("renders a changed room avatar event", () => {
const { container } = render(<Changed />);
expect(container).toMatchSnapshot();
});
it("renders a removed room avatar event", () => {
const { container } = render(<Removed />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,109 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { type JSX, type ReactNode, type Ref } from "react";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useEventPresentationAttributes } from "../../../EventPresentation/EventPresentationContext";
import styles from "./RoomAvatarEventView.module.css";
export interface RoomAvatarEventViewSnapshot {
/**
* Display name for the event sender.
*/
senderDisplayName: string;
/**
* Room name at the time the avatar event is rendered.
*/
roomName: string;
/**
* MXC URL from the avatar event content.
*/
avatarUrl?: string;
/**
* Accessible label for opening the avatar preview.
*/
lightboxLabel: string;
/**
* Whether this event removed the room avatar.
*/
isRemoved: boolean;
}
export interface RoomAvatarEventViewActions {
/**
* Invoked when the user opens the avatar image.
*/
onAvatarClick(): void;
}
export type RoomAvatarEventViewModel = ViewModel<RoomAvatarEventViewSnapshot, RoomAvatarEventViewActions>;
export interface RoomAvatarEventViewProps {
/**
* ViewModel providing room avatar event state and actions.
*/
vm: RoomAvatarEventViewModel;
/**
* Renders the avatar thumbnail using the host application's avatar implementation.
*/
renderAvatar(snapshot: RoomAvatarEventViewSnapshot): ReactNode;
/**
* Optional CSS class names applied to the root element.
*/
className?: string;
/**
* Optional ref forwarded to the root element.
*/
ref?: Ref<HTMLElement>;
}
/**
* Renders a room avatar state event.
*/
export function RoomAvatarEventView({
vm,
renderAvatar,
className,
ref,
}: Readonly<RoomAvatarEventViewProps>): JSX.Element {
const snapshot = useViewModel(vm);
const _t = useI18n().translate;
const eventPresentationAttributes = useEventPresentationAttributes();
const classes = classNames(styles.textualEvent, className);
if (snapshot.isRemoved) {
return (
<div className={classes} ref={ref as Ref<HTMLDivElement>} {...eventPresentationAttributes}>
{_t("timeline|m.room.avatar|removed", { senderDisplayName: snapshot.senderDisplayName })}
</div>
);
}
return (
<span className={classes} ref={ref as Ref<HTMLSpanElement>} {...eventPresentationAttributes}>
{_t(
"timeline|m.room.avatar|changed_img",
{ senderDisplayName: snapshot.senderDisplayName },
{
img: () => (
<button
type="button"
className={styles.avatarButton}
onClick={vm.onAvatarClick}
aria-label={snapshot.lightboxLabel}
>
{renderAvatar(snapshot)}
</button>
),
},
)}
</span>
);
}

View File

@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoomAvatarEventView > renders a changed room avatar event 1`] = `
<div>
<span
class="RoomAvatarEventView-module_textualEvent"
data-event-density="default"
data-event-layout="group"
>
<span>
Alice changed the room avatar to
<button
aria-label="Alice changed the avatar for General"
class="RoomAvatarEventView-module_avatarButton"
type="button"
>
<span
aria-hidden="true"
style="display: inline-block; width: 14px; height: 14px; border-radius: 50%; background: rgb(13, 189, 139);"
/>
</button>
</span>
</span>
</div>
`;
exports[`RoomAvatarEventView > renders a removed room avatar event 1`] = `
<div>
<div
class="RoomAvatarEventView-module_textualEvent"
data-event-density="default"
data-event-layout="group"
>
Alice removed the room avatar.
</div>
</div>
`;

View File

@ -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 "./RoomAvatarEventView";

View File

@ -0,0 +1,64 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
.content {
display: flex;
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-xs);
width: 100%;
overflow-x: auto;
line-height: normal;
}
.source {
flex: 1;
}
pre.source {
line-height: 1.2;
margin: 3.5px 0;
}
.toggle {
--ViewSourceEvent_toggle-size: 16px;
appearance: none;
border: 0;
padding: 0;
background: none;
color: var(--cpd-color-icon-accent-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
visibility: hidden;
width: var(--ViewSourceEvent_toggle-size);
min-width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.content:hover .toggle,
.toggle:focus-visible {
visibility: visible;
}
.toggle:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
}
.toggle svg {
width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.expanded .toggle {
align-self: flex-end;
}

View File

@ -0,0 +1,77 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
ViewSourceEventView,
type ViewSourceEventViewActions,
type ViewSourceEventViewSnapshot,
} from "./ViewSourceEventView";
type ViewSourceEventViewProps = ViewSourceEventViewSnapshot &
ViewSourceEventViewActions & {
className?: string;
expandedClassName?: string;
};
const source = JSON.stringify(
{
type: "m.room.message",
sender: "@alice:example.org",
content: {
msgtype: "m.text",
body: "Hello",
},
},
null,
4,
);
const ViewSourceEventViewWrapperImpl = ({
onToggle,
className,
expandedClassName,
...snapshot
}: ViewSourceEventViewProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onToggle });
return <ViewSourceEventView vm={vm} className={className} expandedClassName={expandedClassName} />;
};
const ViewSourceEventViewWrapper = withViewDocs(ViewSourceEventViewWrapperImpl, ViewSourceEventView);
const meta = {
title: "Timeline/Timeline Event/ViewSourceEventView",
component: ViewSourceEventViewWrapper,
tags: ["autodocs"],
args: {
expanded: false,
preview: '{ "type": m.room.message }',
source,
onToggle: fn(),
className: "",
expandedClassName: "",
},
} satisfies Meta<typeof ViewSourceEventViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Expanded: Story = {
args: {
expanded: true,
},
};

View File

@ -0,0 +1,107 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
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 { fireEvent, render, screen } from "@test-utils";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import {
ViewSourceEventView,
type ViewSourceEventViewActions,
type ViewSourceEventViewModel,
type ViewSourceEventViewSnapshot,
} from "./ViewSourceEventView";
import * as stories from "./ViewSourceEventView.stories";
const { Default, Expanded } = composeStories(stories);
class TestViewSourceEventViewModel
extends MockViewModel<ViewSourceEventViewSnapshot>
implements ViewSourceEventViewActions
{
public constructor(
snapshot: ViewSourceEventViewSnapshot,
public onToggle: ViewSourceEventViewActions["onToggle"],
) {
super(snapshot);
}
}
const createVm = (
snapshot: Partial<ViewSourceEventViewSnapshot> = {},
onToggle: ViewSourceEventViewActions["onToggle"] = vi.fn(),
): ViewSourceEventViewModel =>
new TestViewSourceEventViewModel(
{
expanded: false,
preview: '{ "type": m.room.message }',
source: '{\n "type": "m.room.message"\n}',
...snapshot,
},
onToggle,
) as ViewSourceEventViewModel;
describe("ViewSourceEventView", () => {
const getToggleButton = (container: HTMLElement): HTMLButtonElement => {
const button = container.querySelector<HTMLButtonElement>('button[aria-label="toggle event"]');
if (!button) {
throw new Error("Expected view source toggle button to be rendered");
}
return button;
};
it("renders the default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
expect(screen.getByText('{ "type": m.room.message }')).toBeInTheDocument();
expect(getToggleButton(container)).toBeInTheDocument();
});
it("renders the expanded story", () => {
const { container } = render(<Expanded />);
expect(container).toMatchSnapshot();
expect(screen.getByText(/"sender": "@alice:example\.org"/)).toBeInTheDocument();
});
it("invokes the toggle action", () => {
const onToggle = vi.fn();
const vm = createVm({}, onToggle);
const { container } = render(<ViewSourceEventView vm={vm} />);
fireEvent.click(getToggleButton(container));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("applies custom class names to the root element", () => {
const vm = createVm({ expanded: true });
const { container } = render(
<ViewSourceEventView vm={vm} className="custom-source" expandedClassName="custom-expanded" />,
);
expect(container.firstChild).toHaveClass("custom-source", "custom-expanded");
});
it("forwards the provided ref to the root span", () => {
const ref = React.createRef<HTMLSpanElement>();
const vm = createVm();
render(<ViewSourceEventView vm={vm} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
});
});

View File

@ -0,0 +1,98 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler, type Ref } from "react";
import classNames from "classnames";
import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import styles from "./ViewSourceEventView.module.css";
export interface ViewSourceEventViewSnapshot {
/**
* Whether the full event source is visible.
*/
expanded: boolean;
/**
* Collapsed one-line event summary.
*/
preview: string;
/**
* Pretty-printed event source.
*/
source: string;
}
export interface ViewSourceEventViewActions {
/**
* Invoked when the user expands or collapses the event source.
*/
onToggle: MouseEventHandler<HTMLButtonElement>;
}
export type ViewSourceEventViewModel = ViewModel<ViewSourceEventViewSnapshot, ViewSourceEventViewActions>;
interface ViewSourceEventViewProps {
/**
* ViewModel providing the event source snapshot and actions.
*/
vm: ViewSourceEventViewModel;
/**
* Optional CSS class names applied to the root element.
*/
className?: string;
/**
* Optional CSS class name applied to the root element while expanded.
*/
expandedClassName?: string;
/**
* Optional ref forwarded to the root element.
*/
ref?: Ref<HTMLSpanElement>;
}
/**
* Renders a collapsible event source preview for hidden timeline events.
*/
export function ViewSourceEventView({
vm,
className,
expandedClassName,
ref,
}: Readonly<ViewSourceEventViewProps>): JSX.Element {
const { expanded, preview, source } = useViewModel(vm);
const _t = useI18n().translate;
const toggleLabel = _t("devtools|toggle_event");
const classes = classNames(
styles.content,
className,
{
[styles.expanded]: expanded,
},
expanded && expandedClassName,
);
return (
<span className={classes} ref={ref}>
{expanded ? (
<pre className={styles.source}>{source}</pre>
) : (
<code className={styles.source}>{preview}</code>
)}
<Tooltip description={toggleLabel} placement="top">
<button type="button" aria-label={toggleLabel} className={styles.toggle} onClick={vm.onToggle}>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</button>
</Tooltip>
</span>
);
}

View File

@ -0,0 +1,70 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ViewSourceEventView > renders the default story 1`] = `
<div>
<span
class="ViewSourceEventView-module_content"
>
<code
class="ViewSourceEventView-module_source"
>
{ "type": m.room.message }
</code>
<button
aria-label="toggle event"
class="ViewSourceEventView-module_toggle"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 3.997a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 20 3h-8a1 1 0 1 0 0 2h5.586L5 17.586V12a1 1 0 1 0-2 0v8.003a1 1 0 0 0 .29.702l.005.004c.18.18.43.291.705.291h8a1 1 0 1 0 0-2H6.414L19 6.414V12a1 1 0 1 0 2 0z"
/>
</svg>
</button>
</span>
</div>
`;
exports[`ViewSourceEventView > renders the expanded story 1`] = `
<div>
<span
class="ViewSourceEventView-module_content ViewSourceEventView-module_expanded"
>
<pre
class="ViewSourceEventView-module_source"
>
{
"type": "m.room.message",
"sender": "@alice:example.org",
"content": {
"msgtype": "m.text",
"body": "Hello"
}
}
</pre>
<button
aria-label="toggle event"
class="ViewSourceEventView-module_toggle"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</button>
</span>
</div>
`;

View File

@ -1,13 +1,10 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
.mx_RoomAvatarEvent_avatar {
display: inline;
position: relative;
top: 3px;
}
export * from "./ViewSourceEventView";

View File

@ -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);
}

View File

@ -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,
},
};

View File

@ -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();
});
});
});

View File

@ -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>
);
}

View File

@ -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>
`;

View File

@ -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";

56
pnpm-lock.yaml generated
View File

@ -440,6 +440,9 @@ importers:
is-ip:
specifier: ^5.0.0
version: 5.0.1
jest-silent-reporter:
specifier: ^0.6.0
version: 0.6.0
js-xxhash:
specifier: ^5.0.0
version: 5.0.1
@ -2957,6 +2960,10 @@ packages:
resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/types@26.6.2':
resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==}
engines: {node: '>= 10.14.2'}
'@jest/types@30.3.0':
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@ -5685,6 +5692,9 @@ packages:
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
'@types/yargs@15.0.20':
resolution: {integrity: sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==}
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
@ -6875,6 +6885,9 @@ packages:
chromium-pickle-js@0.2.0:
resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
ci-info@4.3.1:
resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==}
engines: {node: '>=8'}
@ -9070,6 +9083,10 @@ packages:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
is-ci@2.0.0:
resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==}
hasBin: true
is-ci@4.1.0:
resolution: {integrity: sha512-Ab9bQDQ11lWootZUI5qxgN2ZXwxNI5hTwnsvOc1wyxQ7zQ8OkEDw79mI0+9jI3x432NfwbVRru+3noJfXF6lSQ==}
hasBin: true
@ -9438,10 +9455,17 @@ packages:
resolution: {integrity: sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-silent-reporter@0.6.0:
resolution: {integrity: sha512-4nmS+5o7ycVlvbQOTx7CnGdbBtP2646hnDgQpQLaVhjHcQNHD+gqBAetyjRDlgpZ8+8N82MWI59K+EX2LsVk7g==}
jest-snapshot@30.3.0:
resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-util@26.6.2:
resolution: {integrity: sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==}
engines: {node: '>= 10.14.2'}
jest-util@30.3.0:
resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@ -15525,6 +15549,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@jest/types@26.6.2':
dependencies:
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 18.19.130
'@types/yargs': 15.0.20
chalk: 4.1.2
'@jest/types@30.3.0':
dependencies:
'@jest/pattern': 30.0.1
@ -18430,6 +18462,10 @@ snapshots:
'@types/yargs-parser@21.0.3': {}
'@types/yargs@15.0.20':
dependencies:
'@types/yargs-parser': 21.0.3
'@types/yargs@17.0.35':
dependencies:
'@types/yargs-parser': 21.0.3
@ -19879,6 +19915,8 @@ snapshots:
chromium-pickle-js@0.2.0: {}
ci-info@2.0.0: {}
ci-info@4.3.1: {}
ci-info@4.4.0: {}
@ -22440,6 +22478,10 @@ snapshots:
is-callable@1.2.7: {}
is-ci@2.0.0:
dependencies:
ci-info: 2.0.0
is-ci@4.1.0:
dependencies:
ci-info: 4.4.0
@ -22925,6 +22967,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
jest-silent-reporter@0.6.0:
dependencies:
chalk: 4.1.2
jest-util: 26.6.2
jest-snapshot@30.3.0:
dependencies:
'@babel/core': 7.29.0
@ -22951,6 +22998,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
jest-util@26.6.2:
dependencies:
'@jest/types': 26.6.2
'@types/node': 18.19.130
chalk: 4.1.2
graceful-fs: 4.2.11
is-ci: 2.0.0
micromatch: 4.0.8
jest-util@30.3.0:
dependencies:
'@jest/types': 30.3.0