Merge branch 'develop' into midhun/call-tiles/call-declined-1
@ -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
|
||||
@ -751,7 +751,7 @@ test.describe("Timeline", () => {
|
||||
await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should render file size in kibibytes on a file tile", async ({ page, room }) => {
|
||||
test("should render file size in kibibytes on a file tile", async ({ page, app, room }) => {
|
||||
await page.goto(`/#/room/${room.roomId}`);
|
||||
await expect(
|
||||
page
|
||||
@ -760,12 +760,7 @@ test.describe("Timeline", () => {
|
||||
).toBeVisible();
|
||||
|
||||
// Upload a file from the message composer
|
||||
await page
|
||||
.locator(".mx_MessageComposer_actions input[type='file']")
|
||||
.setInputFiles(getSampleFilePath("matrix-org-client-versions.json"));
|
||||
|
||||
// Click "Upload" button
|
||||
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
|
||||
await app.composerUploadFiles("room", getSampleFilePath("matrix-org-client-versions.json"));
|
||||
|
||||
// Wait until the file is sent
|
||||
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
|
||||
|
||||
@ -169,8 +169,7 @@ export class ElementAppPage {
|
||||
): ReturnType<Locator["setInputFiles"]> {
|
||||
const input = this.page
|
||||
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
|
||||
.getByRole("region", { name: "Message composer" })
|
||||
.locator("input[type='file']");
|
||||
.getByTestId("room-upload-context-input");
|
||||
return input.setInputFiles(...params);
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
@ -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";
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -607,7 +607,7 @@ export default class ContentMessages {
|
||||
throw e;
|
||||
}
|
||||
// Otherwise we failed to thumbnail, fall back to uploading an m.file
|
||||
logger.error(e);
|
||||
logger.error(`Expected file of type "${file.type}" to be an image, but got`, e);
|
||||
content.msgtype = MsgType.File;
|
||||
}
|
||||
} else if (file.type.startsWith("audio/")) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -7,16 +8,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { useViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../../languageHandler";
|
||||
import UploadBigSvg from "../../../res/img/upload-big.svg";
|
||||
import { useRoomState } from "../../hooks/useRoomState.ts";
|
||||
import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
parent: HTMLElement | null;
|
||||
onFileDrop(this: void, dataTransfer: DataTransfer): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@ -24,15 +23,16 @@ interface IState {
|
||||
counter: number;
|
||||
}
|
||||
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent }) => {
|
||||
const [state, setState] = useState<IState>({
|
||||
dragging: false,
|
||||
counter: 0,
|
||||
});
|
||||
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!));
|
||||
const vm = useRoomUploadViewModel();
|
||||
const { mayUpload } = useViewModel(vm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPermission || !parent || parent.ondrop) return;
|
||||
if (!mayUpload || !parent || parent.ondrop) return;
|
||||
|
||||
const onDragEnter = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
@ -83,7 +83,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!ev.dataTransfer) return;
|
||||
onFileDrop(ev.dataTransfer);
|
||||
void vm.initiateViaDataTransfer(ev.dataTransfer);
|
||||
|
||||
setState((state) => ({
|
||||
dragging: false,
|
||||
@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
parent?.removeEventListener("dragenter", onDragEnter);
|
||||
parent?.removeEventListener("dragleave", onDragLeave);
|
||||
};
|
||||
}, [parent, onFileDrop, hasPermission]);
|
||||
}, [parent, mayUpload, vm]);
|
||||
|
||||
if (hasPermission && state.dragging) {
|
||||
if (mayUpload && state.dragging) {
|
||||
return (
|
||||
<div className="mx_FileDropTarget">
|
||||
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
||||
|
||||
@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
const DEBUG = false;
|
||||
@ -302,7 +303,6 @@ interface LocalRoomViewProps {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
roomView: RefObject<HTMLElement | null>;
|
||||
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
mainSplitContentType: MainSplitContentType;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
||||
<div className="mx_RoomView mx_RoomView--local">
|
||||
<ErrorBoundary>
|
||||
<RoomHeader room={room} />
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel">
|
||||
{encryptionTile}
|
||||
<NewRoomIntro />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
{statusBar}
|
||||
{composer}
|
||||
</main>
|
||||
<RoomUploadContextProvider>
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel">
|
||||
{encryptionTile}
|
||||
<NewRoomIntro />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
{statusBar}
|
||||
{composer}
|
||||
</main>
|
||||
</RoomUploadContextProvider>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
@ -2121,19 +2123,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
private onFileDrop = async (dataTransfer: DataTransfer): Promise<void> => {
|
||||
const roomId = this.getRoomId();
|
||||
if (!roomId || !this.context.client) return;
|
||||
await ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
undefined,
|
||||
this.state.replyToEvent,
|
||||
this.context.client,
|
||||
TimelineRenderingType.Room,
|
||||
);
|
||||
};
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
@ -2169,7 +2158,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
resizeNotifier={this.context.resizeNotifier}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
roomView={this.roomView}
|
||||
onFileDrop={this.onFileDrop}
|
||||
mainSplitContentType={this.state.mainSplitContentType}
|
||||
/>
|
||||
</ScopedRoomContextProvider>
|
||||
@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
case MainSplitContentType.Timeline:
|
||||
mainSplitContentClassName = "mx_MainSplit_timeline";
|
||||
mainSplitBody = (
|
||||
<>
|
||||
<RoomUploadContextProvider>
|
||||
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
|
||||
{auxPanel}
|
||||
{pinnedMessageBanner}
|
||||
<main className={timelineClasses} data-testid="timeline">
|
||||
<FileDropTarget
|
||||
parent={this.roomView.current}
|
||||
onFileDrop={this.onFileDrop}
|
||||
room={this.state.room}
|
||||
/>
|
||||
<FileDropTarget parent={this.roomView.current} />
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
{statusBarArea}
|
||||
{previewBar}
|
||||
{messageComposer}
|
||||
</>
|
||||
</RoomUploadContextProvider>
|
||||
);
|
||||
break;
|
||||
case MainSplitContentType.MaximisedWidget:
|
||||
|
||||
@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { type ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { type E2EStatus } from "../../utils/ShieldUtils";
|
||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
|
||||
import Heading from "../views/typography/Heading";
|
||||
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
|
||||
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
@ -329,22 +329,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onFileDrop = (dataTransfer: DataTransfer): void => {
|
||||
const roomId = this.props.mxEvent.getRoomId();
|
||||
if (roomId) {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
this.threadRelation,
|
||||
this.context.replyToEvent,
|
||||
MatrixClientPeg.safeGet(),
|
||||
TimelineRenderingType.Thread,
|
||||
);
|
||||
} else {
|
||||
console.warn("Unknwon roomId for event", this.props.mxEvent);
|
||||
}
|
||||
};
|
||||
|
||||
private get threadRelation(): IEventRelation {
|
||||
const relation: IEventRelation = {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
@ -393,7 +377,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
|
||||
timeline = (
|
||||
<>
|
||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
|
||||
<FileDropTarget parent={this.card.current} />
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
key={this.state.thread.id}
|
||||
@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
|
||||
narrow={this.state.narrow}
|
||||
>
|
||||
<BaseCard
|
||||
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
||||
mx_ThreadView_narrow: this.state.narrow,
|
||||
})}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderThreadViewHeader()}
|
||||
ref={this.card}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBack={(ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||
}}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||
<RoomUploadContextProvider threadRelation={this.threadRelation}>
|
||||
<BaseCard
|
||||
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
||||
mx_ThreadView_narrow: this.state.narrow,
|
||||
})}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderThreadViewHeader()}
|
||||
ref={this.card}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBack={(ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||
}}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||
|
||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||
)}
|
||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||
)}
|
||||
|
||||
{this.state.thread?.timelineSet && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
relation={threadRelation}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
{this.state.thread?.timelineSet && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
relation={threadRelation}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
||||
import Measured from "../elements/Measured";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
header={_t("right_panel|video_room_chat|title")}
|
||||
ref={this.card}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
<RoomUploadContextProvider>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</RoomUploadContextProvider>
|
||||
</BaseCard>
|
||||
</ScopedRoomContextProvider>
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -14,7 +15,7 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
M_POLL_START,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
|
||||
import {
|
||||
AttachmentIcon,
|
||||
MicOnIcon,
|
||||
@ -27,22 +28,19 @@ import {
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { CollapsibleButton } from "./CollapsibleButton";
|
||||
import { type MenuProps } from "../../structures/ContextMenu";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { LocationButton } from "../location";
|
||||
import Modal from "../../../Modal";
|
||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
interface IProps {
|
||||
addEmoji: (emoji: string) => boolean;
|
||||
@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
|
||||
<>
|
||||
{mainButtons}
|
||||
{moreButtons.length > 0 && (
|
||||
<AccessibleButton
|
||||
@ -149,7 +147,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
</OverflowMenuContext.Provider>
|
||||
</IconizedContextMenu>
|
||||
)}
|
||||
</UploadButtonContextProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -168,79 +166,13 @@ function uploadButton(): ReactElement {
|
||||
return <UploadButton key="controls_upload" />;
|
||||
}
|
||||
|
||||
type UploadButtonFn = () => void;
|
||||
export const UploadButtonContext = createContext<UploadButtonFn | null>(null);
|
||||
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
relation?: IEventRelation;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes.
|
||||
const UploadButtonContextProvider: React.FC<IUploadButtonProps> = ({ roomId, relation, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUploadClick = (): void => {
|
||||
if (cli?.isGuest()) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
|
||||
useDispatcher(dis, (payload) => {
|
||||
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
|
||||
onUploadClick();
|
||||
}
|
||||
});
|
||||
|
||||
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (ev.target.files?.length === 0) return;
|
||||
|
||||
// Take a copy, so we can safely reset the value of the form control
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(ev.target.files!),
|
||||
roomId,
|
||||
relation,
|
||||
roomContext.replyToEvent,
|
||||
cli,
|
||||
roomContext.timelineRenderingType,
|
||||
);
|
||||
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
// not keeping any state, so reset the value of the form control
|
||||
// to empty.
|
||||
// NB. we need to set 'value': the 'files' property is immutable.
|
||||
ev.target.value = "";
|
||||
};
|
||||
|
||||
const uploadInputStyle = { display: "none" };
|
||||
return (
|
||||
<UploadButtonContext.Provider value={onUploadClick}>
|
||||
{children}
|
||||
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
style={uploadInputStyle}
|
||||
multiple
|
||||
onClick={chromeFileInputFix}
|
||||
onChange={onUploadFileInputChange}
|
||||
/>
|
||||
</UploadButtonContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Must be rendered within an UploadButtonContextProvider
|
||||
const UploadButton: React.FC = () => {
|
||||
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||
const uploadButtonFn = useContext(UploadButtonContext);
|
||||
const vm = useRoomUploadViewModel();
|
||||
|
||||
const onClick = (): void => {
|
||||
uploadButtonFn?.();
|
||||
vm.openUploadDialog();
|
||||
overflowMenuCloser?.(); // close overflow menu
|
||||
};
|
||||
|
||||
|
||||
@ -6,7 +6,13 @@ 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, { createRef, type KeyboardEvent, type SyntheticEvent } from "react";
|
||||
import React, {
|
||||
createRef,
|
||||
type KeyboardEvent,
|
||||
type SyntheticEvent,
|
||||
type RefAttributes,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
type MatrixEvent,
|
||||
type IEventRelation,
|
||||
@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart }
|
||||
import { findEditableEvent } from "../../../utils/EventUtils";
|
||||
import SendHistoryManager from "../../../SendHistoryManager";
|
||||
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||
import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { containsEmoji } from "../../../effects/utils";
|
||||
import { CHAT_EFFECTS } from "../../../effects";
|
||||
@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff";
|
||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||
import { EMOJI_REGEX } from "../../../HtmlUtils";
|
||||
import { attachMentions, attachRelation } from "../../../utils/messages";
|
||||
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
// The prefix used when persisting editor drafts to localstorage.
|
||||
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
|
||||
@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
|
||||
|
||||
interface ISendMessageComposerProps extends MatrixClientProps {
|
||||
room: Room;
|
||||
uploadVm: RoomUploadViewModel;
|
||||
placeholder?: string;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
@ -561,14 +568,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
||||
// it puts the filename in as text/plain which we want to ignore.
|
||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(data.files),
|
||||
this.props.room.roomId,
|
||||
this.props.relation,
|
||||
this.context.replyToEvent,
|
||||
this.props.mxClient,
|
||||
this.context.timelineRenderingType,
|
||||
);
|
||||
this.props.uploadVm.initiateViaDataTransfer(data);
|
||||
return true; // to skip internal onPaste handler
|
||||
}
|
||||
|
||||
@ -601,13 +601,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
const parts = response.url.split("/");
|
||||
const filename = parts[parts.length - 1];
|
||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||
ContentMessages.sharedInstance().sendContentToRoom(
|
||||
file,
|
||||
this.props.room.roomId,
|
||||
this.props.relation,
|
||||
this.props.mxClient,
|
||||
this.context.replyToEvent,
|
||||
);
|
||||
this.props.uploadVm.initiateViaInputFiles([file]);
|
||||
},
|
||||
(error) => {
|
||||
console.log(error);
|
||||
@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
}
|
||||
}
|
||||
|
||||
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
|
||||
export default SendMessageComposerWithMatrixClient;
|
||||
function SendMessageComposerWrapped(
|
||||
props: Omit<ISendMessageComposerProps, "mxClient" | "uploadVm"> &
|
||||
RefAttributes<InstanceType<typeof SendMessageComposer>>,
|
||||
): ReactElement {
|
||||
const client = useMatrixClientContext();
|
||||
const uploadVm = useRoomUploadViewModel();
|
||||
return <SendMessageComposer {...props} mxClient={client} uploadVm={uploadVm} />;
|
||||
}
|
||||
|
||||
export default SendMessageComposerWrapped;
|
||||
|
||||
@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, type RefObject, type ReactNode } from "react";
|
||||
import React, { type JSX, type RefObject, type ReactNode, useContext } from "react";
|
||||
|
||||
import { useComposerFunctions } from "../hooks/useComposerFunctions";
|
||||
import { useIsFocused } from "../hooks/useIsFocused";
|
||||
@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types";
|
||||
import { Editor } from "./Editor";
|
||||
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
interface PlainTextComposerProps {
|
||||
disabled?: boolean;
|
||||
@ -46,6 +47,7 @@ export function PlainTextComposer({
|
||||
eventRelation,
|
||||
}: PlainTextComposerProps): JSX.Element {
|
||||
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||
const uploadContext = useContext(RoomUploadContext);
|
||||
const {
|
||||
ref: editorRef,
|
||||
autocompleteRef,
|
||||
@ -61,7 +63,7 @@ export function PlainTextComposer({
|
||||
handleMention,
|
||||
handleAtRoomMention,
|
||||
handleEmoji,
|
||||
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
||||
} = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined);
|
||||
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
||||
usePlainTextInitialization(initialContent, editorRef);
|
||||
useSetCursorPosition(disabled, editorRef);
|
||||
|
||||
@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
||||
const { room } = useScopedRoomContext("room");
|
||||
const autocompleteRef = useRef<Autocomplete | null>(null);
|
||||
|
||||
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
|
||||
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent);
|
||||
|
||||
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
|
||||
|
||||
@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { type Wysiwyg, type WysiwygEvent } from "@vector-im/matrix-wysiwyg";
|
||||
import { useCallback } from "react";
|
||||
import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||
@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing";
|
||||
import type Autocomplete from "../../Autocomplete";
|
||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
export function useInputEventProcessor(
|
||||
onSend: () => void,
|
||||
autocompleteRef: React.RefObject<Autocomplete | null>,
|
||||
initialContent?: string,
|
||||
eventRelation?: IEventRelation,
|
||||
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
|
||||
const roomUploadVm = useRoomUploadViewModel();
|
||||
const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType");
|
||||
const composerContext = useComposerContext();
|
||||
const mxClient = useMatrixClientContext();
|
||||
@ -52,7 +53,7 @@ export function useInputEventProcessor(
|
||||
|
||||
if (isEventToHandleAsClipboardEvent(event)) {
|
||||
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
|
||||
const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation);
|
||||
const handled = handleClipboardEvent(event, data, roomUploadVm);
|
||||
return handled ? null : event;
|
||||
}
|
||||
|
||||
@ -77,11 +78,11 @@ export function useInputEventProcessor(
|
||||
isCtrlEnterToSend,
|
||||
onSend,
|
||||
initialContent,
|
||||
roomUploadVm,
|
||||
roomContext,
|
||||
composerContext,
|
||||
mxClient,
|
||||
autocompleteRef,
|
||||
eventRelation,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { type KeyboardEvent, type RefObject, type SyntheticEvent, useCallback, useRef, useState } from "react";
|
||||
import { type AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg";
|
||||
import { type IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
||||
@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete";
|
||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||
import { useSuggestion } from "./useSuggestion";
|
||||
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||
return target instanceof HTMLDivElement;
|
||||
@ -46,8 +44,8 @@ export function usePlainTextListeners(
|
||||
initialContent?: string,
|
||||
onChange?: (content: string) => void,
|
||||
onSend?: () => void,
|
||||
eventRelation?: IEventRelation,
|
||||
isAutoReplaceEmojiEnabled?: boolean,
|
||||
roomUploadVM?: RoomUploadViewModel,
|
||||
): {
|
||||
ref: RefObject<HTMLDivElement | null>;
|
||||
autocompleteRef: RefObject<Autocomplete | null>;
|
||||
@ -64,9 +62,6 @@ export function usePlainTextListeners(
|
||||
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
|
||||
suggestion: MappedSuggestion | null;
|
||||
} {
|
||||
const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent");
|
||||
const mxClient = useMatrixClientContext();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const autocompleteRef = useRef<Autocomplete>(null);
|
||||
const [content, setContent] = useState<string | undefined>(initialContent);
|
||||
@ -120,10 +115,10 @@ export function usePlainTextListeners(
|
||||
const { nativeEvent } = event;
|
||||
let imagePasteWasHandled = false;
|
||||
|
||||
if (isEventToHandleAsClipboardEvent(nativeEvent)) {
|
||||
if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) {
|
||||
const data =
|
||||
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer;
|
||||
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation);
|
||||
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomUploadVM);
|
||||
}
|
||||
|
||||
// prevent default behaviour and skip call to onInput if the image paste event was handled
|
||||
@ -133,7 +128,7 @@ export function usePlainTextListeners(
|
||||
onInput(event);
|
||||
}
|
||||
},
|
||||
[eventRelation, mxClient, onInput, roomContext],
|
||||
[onInput, roomUploadVM],
|
||||
);
|
||||
|
||||
const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend");
|
||||
|
||||
@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RefObject } from "react";
|
||||
import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type WysiwygEvent } from "@vector-im/matrix-wysiwyg";
|
||||
|
||||
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||
@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete";
|
||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
|
||||
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
|
||||
import ContentMessages from "../../../../../ContentMessages";
|
||||
import { isNotNull } from "../../../../../Typeguards";
|
||||
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
export function focusComposer(
|
||||
composerElement: RefObject<HTMLElement | null>,
|
||||
@ -123,13 +122,8 @@ export function handleEventWithAutocomplete(
|
||||
export function handleClipboardEvent(
|
||||
event: ClipboardEvent | InputEvent,
|
||||
data: DataTransfer | null,
|
||||
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">,
|
||||
mxClient: MatrixClient,
|
||||
eventRelation?: IEventRelation,
|
||||
vm: RoomUploadViewModel,
|
||||
): boolean {
|
||||
// Logic in this function follows that of `SendMessageComposer.onPaste`
|
||||
const { room, timelineRenderingType, replyToEvent } = roomContext;
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
console.log(error.message);
|
||||
@ -138,7 +132,7 @@ export function handleClipboardEvent(
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type !== "paste" || data === null || room === undefined) {
|
||||
if (event.type !== "paste" || data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -147,16 +141,7 @@ export function handleClipboardEvent(
|
||||
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
||||
// it puts the filename in as text/plain which we want to ignore.
|
||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||
ContentMessages.sharedInstance()
|
||||
.sendContentListToRoom(
|
||||
Array.from(data.files),
|
||||
room.roomId,
|
||||
eventRelation,
|
||||
roomContext.replyToEvent,
|
||||
mxClient,
|
||||
timelineRenderingType,
|
||||
)
|
||||
.catch(handleError);
|
||||
vm.initiateViaDataTransfer(data).catch(handleError);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -188,9 +173,7 @@ export function handleClipboardEvent(
|
||||
const parts = response.url.split("/");
|
||||
const filename = parts[parts.length - 1];
|
||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||
ContentMessages.sharedInstance()
|
||||
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
|
||||
.catch(handleError);
|
||||
return vm.initiateViaInputFiles([file]);
|
||||
})
|
||||
.catch(handleError);
|
||||
})
|
||||
|
||||
@ -22,8 +22,11 @@ import {
|
||||
CallStartedTileView,
|
||||
EncryptionEventView,
|
||||
HiddenBodyView,
|
||||
MJitsiWidgetEventView,
|
||||
MKeyVerificationRequestView,
|
||||
RoomAvatarEventView,
|
||||
TextualEventView,
|
||||
ViewSourceEventView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
@ -35,23 +38,24 @@ 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 { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel";
|
||||
|
||||
@ -125,6 +129,65 @@ 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, getRelationsForEvent }: IBodyProps): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent }));
|
||||
return vm.isCallDeclined ? <CallDeclinedTileView vm={vm} /> : <CallStartedTileView vm={vm} />;
|
||||
@ -135,8 +198,8 @@ export const CallStartedEventFactory: Factory = (ref, 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>([
|
||||
@ -156,7 +219,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],
|
||||
|
||||
@ -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": {
|
||||
|
||||
231
apps/web/src/viewmodels/room/RoomUploadViewModel.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
import React, {
|
||||
type ChangeEventHandler,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type IEventRelation,
|
||||
type MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
|
||||
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||
import ContentMessages from "../../ContentMessages";
|
||||
import type { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import { chromeFileInputFix } from "../../utils/BrowserWorkarounds";
|
||||
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
|
||||
const logger = rootLogger.getChild("RoomUploadViewModel");
|
||||
|
||||
export interface RoomUploadViewSnapshot {
|
||||
mayUpload: boolean;
|
||||
}
|
||||
|
||||
export interface RoomUploadViewActions {
|
||||
initiateViaInputFiles(files: FileList | null): Promise<void>;
|
||||
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
|
||||
openUploadDialog(): void;
|
||||
}
|
||||
|
||||
export class RoomUploadViewModel
|
||||
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
|
||||
implements RoomUploadViewActions
|
||||
{
|
||||
public constructor(
|
||||
private readonly room: Room,
|
||||
private readonly client: MatrixClient,
|
||||
private readonly timelineRenderingType: TimelineRenderingType,
|
||||
private readonly dispatcher: MatrixDispatcher,
|
||||
private replyToEvent: MatrixEvent | undefined,
|
||||
private threadRelation: IEventRelation | undefined,
|
||||
public readonly openUploadDialog: () => void,
|
||||
) {
|
||||
super(
|
||||
{},
|
||||
{
|
||||
mayUpload: room.maySendMessage(),
|
||||
},
|
||||
);
|
||||
room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||
this.disposables.track(() => {
|
||||
room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated);
|
||||
});
|
||||
}
|
||||
|
||||
private onRoomCurrentStateUpdated = (): void => {
|
||||
this.snapshot.merge({
|
||||
mayUpload: this.room.maySendMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
public setReplyToEvent = (replyToEvent?: MatrixEvent): void => {
|
||||
this.replyToEvent = replyToEvent;
|
||||
};
|
||||
|
||||
public setThreadRelation = (threadRelation?: IEventRelation): void => {
|
||||
this.threadRelation = threadRelation;
|
||||
};
|
||||
|
||||
public initiateViaInputFiles = async (files: FileList | File[] | null): Promise<void> => {
|
||||
if (!this.checkCanUpload()) {
|
||||
return;
|
||||
}
|
||||
const { roomId } = this.room;
|
||||
logger.info("initiateViaInputFiles for", roomId);
|
||||
if (!files?.length) return;
|
||||
|
||||
try {
|
||||
await ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(files),
|
||||
roomId,
|
||||
this.threadRelation,
|
||||
this.replyToEvent,
|
||||
this.client,
|
||||
this.timelineRenderingType,
|
||||
);
|
||||
} catch (ex) {
|
||||
logger.warn("Failed to handle file upload transfer", ex);
|
||||
}
|
||||
};
|
||||
|
||||
public initiateViaDataTransfer = async (dataTransfer: DataTransfer): Promise<void> => {
|
||||
if (!this.checkCanUpload()) {
|
||||
return;
|
||||
}
|
||||
const { roomId } = this.room;
|
||||
logger.info("initiateViaDataTransfer for", roomId);
|
||||
if (!dataTransfer.files?.length) return;
|
||||
|
||||
try {
|
||||
await ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
this.threadRelation,
|
||||
this.replyToEvent,
|
||||
this.client,
|
||||
this.timelineRenderingType,
|
||||
);
|
||||
} catch (ex) {
|
||||
logger.warn("Failed to handle drag and drop data transfer", ex);
|
||||
}
|
||||
};
|
||||
|
||||
private checkCanUpload(): boolean {
|
||||
if (this.client.isGuest()) {
|
||||
this.dispatcher.dispatch({ action: "require_registration" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const RoomUploadContext = createContext<RoomUploadViewModel | null>(null);
|
||||
|
||||
export function useRoomUploadViewModel(): RoomUploadViewModel {
|
||||
const ctx = useContext(RoomUploadContext);
|
||||
if (!ctx) {
|
||||
throw new Error("RoomFileUploadProvider is not present");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function RoomUploadContextProvider({
|
||||
children,
|
||||
threadRelation,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
threadRelation?: IEventRelation;
|
||||
}): ReactNode {
|
||||
const { room, timelineRenderingType, replyToEvent } = useScopedRoomContext(
|
||||
"room",
|
||||
"timelineRenderingType",
|
||||
"replyToEvent",
|
||||
);
|
||||
const client = useMatrixClientContext();
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const openFilePicker = useCallback((): void => {
|
||||
if (!uploadInput.current) {
|
||||
throw new Error("Input not ready");
|
||||
}
|
||||
uploadInput.current.click();
|
||||
}, [uploadInput]);
|
||||
|
||||
const vm = useCreateAutoDisposedViewModel(() => {
|
||||
if (!room) {
|
||||
throw new Error("RoomUploadContextProvider must have a room");
|
||||
}
|
||||
return new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
timelineRenderingType,
|
||||
defaultDispatcher,
|
||||
replyToEvent,
|
||||
threadRelation,
|
||||
openFilePicker,
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
vm.setReplyToEvent(replyToEvent);
|
||||
}, [vm, replyToEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setThreadRelation(threadRelation);
|
||||
}, [vm, threadRelation]);
|
||||
|
||||
const onInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(ev) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await vm.initiateViaInputFiles(ev.target.files);
|
||||
} finally {
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
// not keeping any state, so reset the value of the form control
|
||||
// to empty.
|
||||
// NB. we need to set 'value': the 'files' property is immutable.
|
||||
ev.target.value = "";
|
||||
}
|
||||
})();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
|
||||
// Note, while this logic could be largely replaced with https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker
|
||||
// it does not enjoy support across all our target platforms.
|
||||
// Therefore, we use the invisible input element trick.
|
||||
|
||||
return (
|
||||
<RoomUploadContext.Provider value={vm}>
|
||||
<>
|
||||
{children}
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
data-testid="room-upload-context-input"
|
||||
style={{ display: "none" }}
|
||||
multiple
|
||||
onClick={chromeFileInputFix}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</>
|
||||
</RoomUploadContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -659,8 +659,8 @@ export function mkMessage({
|
||||
|
||||
export function mkStubRoom(
|
||||
roomId: string | null | undefined = null,
|
||||
name: string | undefined,
|
||||
client: MatrixClient | undefined,
|
||||
name?: string | undefined,
|
||||
client?: MatrixClient | undefined,
|
||||
state?: RoomState | undefined,
|
||||
): Room {
|
||||
const stubTimeline = {
|
||||
|
||||
@ -6,35 +6,62 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { render, fireEvent } from "jest-matrix-react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { useMockedViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx";
|
||||
import { stubClient } from "../../../test-utils";
|
||||
import {
|
||||
RoomUploadContext,
|
||||
type RoomUploadViewActions,
|
||||
type RoomUploadViewModel,
|
||||
type RoomUploadViewSnapshot,
|
||||
} from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
function FileDropTargetWrapped({
|
||||
element,
|
||||
snapshot,
|
||||
actions,
|
||||
}: {
|
||||
element: HTMLDivElement;
|
||||
snapshot: RoomUploadViewSnapshot;
|
||||
actions: Partial<RoomUploadViewActions>;
|
||||
}) {
|
||||
const mockVm = useMockedViewModel<RoomUploadViewSnapshot, RoomUploadViewActions>(
|
||||
snapshot,
|
||||
actions as RoomUploadViewActions,
|
||||
);
|
||||
return (
|
||||
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
|
||||
<FileDropTarget parent={element} />
|
||||
</RoomUploadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("FileDropTarget", () => {
|
||||
let room: Room;
|
||||
beforeEach(() => {
|
||||
const client = stubClient();
|
||||
room = new Room("!roomId:example.com", client, client.getUserId()!);
|
||||
room.currentState.maySendMessage = jest.fn().mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should render nothing when idle", () => {
|
||||
const element = document.createElement("div");
|
||||
const onFileDrop = jest.fn();
|
||||
|
||||
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
|
||||
const { asFragment } = render(
|
||||
<FileDropTargetWrapped
|
||||
element={element}
|
||||
snapshot={{ mayUpload: true }}
|
||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
||||
/>,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render drop file prompt on mouse over with file if permissions allow", () => {
|
||||
const element = document.createElement("div");
|
||||
const onFileDrop = jest.fn();
|
||||
mocked(room.currentState.maySendMessage).mockReturnValue(true);
|
||||
|
||||
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
|
||||
const { asFragment } = render(
|
||||
<FileDropTargetWrapped
|
||||
element={element}
|
||||
snapshot={{ mayUpload: true }}
|
||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.dragEnter(element, {
|
||||
dataTransfer: {
|
||||
types: ["Files"],
|
||||
@ -46,9 +73,13 @@ describe("FileDropTarget", () => {
|
||||
it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
|
||||
const element = document.createElement("div");
|
||||
const onFileDrop = jest.fn();
|
||||
mocked(room.currentState.maySendMessage).mockReturnValue(false);
|
||||
|
||||
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
|
||||
const { asFragment } = render(
|
||||
<FileDropTargetWrapped
|
||||
element={element}
|
||||
snapshot={{ mayUpload: false }}
|
||||
actions={{ initiateViaDataTransfer: onFileDrop }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.dragEnter(element, {
|
||||
dataTransfer: {
|
||||
types: ["Files"],
|
||||
|
||||
@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
data-testid="room-upload-context-input"
|
||||
multiple=""
|
||||
style="display: none;"
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div>
|
||||
|
||||
@ -36,6 +36,10 @@ import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
|
||||
import { Action } from "../../../../../src/dispatcher/actions";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import {
|
||||
RoomUploadContext,
|
||||
type RoomUploadViewModel,
|
||||
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
const openStickerPicker = async (): Promise<void> => {
|
||||
await userEvent.click(screen.getByLabelText("More options"));
|
||||
@ -469,7 +473,9 @@ function wrapAndRender(
|
||||
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...context}>
|
||||
<MessageComposer {...defaultProps} {...props} />
|
||||
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
|
||||
<MessageComposer {...defaultProps} {...props} />
|
||||
</RoomUploadContext.Provider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
|
||||
@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
describe("MessageComposerButtons", () => {
|
||||
// @ts-ignore - we're deliberately not implementing the whole interface here, but
|
||||
@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => {
|
||||
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext}>{component}</ScopedRoomContextProvider>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||
<RoomUploadContextProvider>{component}</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
|
||||
import { addTextToComposer } from "../../../../test-utils/composer";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts";
|
||||
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
jest.mock("../../../../../src/utils/local-room", () => ({
|
||||
doMaybeLocalRoomAction: jest.fn(),
|
||||
@ -187,8 +188,10 @@ describe("<SendMessageComposer/>", () => {
|
||||
};
|
||||
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
<SendMessageComposer {...defaultProps} {...props} />
|
||||
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
|
||||
<RoomUploadContextProvider>
|
||||
<SendMessageComposer {...defaultProps} {...props} />
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
@ -435,7 +438,11 @@ describe("<SendMessageComposer/>", () => {
|
||||
|
||||
const { container } = render(
|
||||
<MatrixClientContext.Provider value={cli}>
|
||||
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
|
||||
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
|
||||
<RoomUploadContextProvider>
|
||||
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads";
|
||||
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
|
||||
import { createMocks } from "./utils";
|
||||
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
beforeAll(initOnce, 10000);
|
||||
|
||||
@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||
<RoomUploadContextProvider>
|
||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => {
|
||||
rerender(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
|
||||
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
||||
<RoomUploadContextProvider>
|
||||
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => {
|
||||
render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||
<RoomUploadContextProvider>
|
||||
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||
</RoomUploadContextProvider>
|
||||
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
|
||||
@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw
|
||||
import { createMocks } from "./utils";
|
||||
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts";
|
||||
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
|
||||
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||
@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||
<SendWysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
isRichTextEnabled={isRichTextEnabled}
|
||||
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
||||
placeholder={placeholder}
|
||||
e2eStatus={e2eStatus}
|
||||
/>
|
||||
<RoomUploadContextProvider>
|
||||
<SendWysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
isRichTextEnabled={isRichTextEnabled}
|
||||
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
||||
placeholder={placeholder}
|
||||
e2eStatus={e2eStatus}
|
||||
/>
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut
|
||||
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
|
||||
import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
|
||||
import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
beforeAll(initOnce, 10000);
|
||||
|
||||
@ -42,12 +43,14 @@ describe("WysiwygComposer", () => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||
<WysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
<RoomUploadContextProvider>
|
||||
<WysiwygComposer
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={disabled}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
@ -561,19 +564,21 @@ describe("WysiwygComposer", () => {
|
||||
return render(
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
<ComposerContext.Provider
|
||||
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
||||
>
|
||||
<WysiwygComposer
|
||||
onChange={jest.fn()}
|
||||
onSend={jest.fn()}
|
||||
initialContent={
|
||||
roomContext.room && _editorStateTransfer
|
||||
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ComposerContext.Provider>
|
||||
<RoomUploadContextProvider>
|
||||
<ComposerContext.Provider
|
||||
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
||||
>
|
||||
<WysiwygComposer
|
||||
onChange={jest.fn()}
|
||||
onSend={jest.fn()}
|
||||
initialContent={
|
||||
roomContext.room && _editorStateTransfer
|
||||
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ComposerContext.Provider>
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
@ -5,29 +5,21 @@ Copyright 2023 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 IEventRelation, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
|
||||
import { TimelineRenderingType } from "../../../../../../../src/contexts/RoomContext";
|
||||
import { mkStubRoom, stubClient } from "../../../../../../test-utils";
|
||||
import ContentMessages from "../../../../../../../src/ContentMessages";
|
||||
import { type IRoomState } from "../../../../../../../src/components/structures/RoomView";
|
||||
import {
|
||||
handleClipboardEvent,
|
||||
isEventToHandleAsClipboardEvent,
|
||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils";
|
||||
import type { RoomUploadViewModel } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
const mockClient = stubClient();
|
||||
const mockRoom = mkStubRoom("mock room", "mock room", mockClient);
|
||||
const mockRoomState = {
|
||||
room: mockRoom,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
replyToEvent: {} as unknown as MatrixEvent,
|
||||
} as unknown as IRoomState;
|
||||
const mockUploadVM = {
|
||||
initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined),
|
||||
initiateViaInputFiles: jest.fn().mockResolvedValue(undefined),
|
||||
} as Partial<RoomUploadViewModel> as MockedObject<RoomUploadViewModel>;
|
||||
|
||||
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
||||
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
describe("handleClipboardEvent", () => {
|
||||
@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => {
|
||||
|
||||
it("returns false if it is not a paste event", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "copy" });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
expect(output).toBe(false);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false if clipboard data is null", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if room is undefined", () => {
|
||||
const originalEvent = createMockClipboardEvent({ type: "paste" });
|
||||
const { room, ...roomStateWithoutRoom } = mockRoomState;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
roomStateWithoutRoom,
|
||||
mockClient,
|
||||
);
|
||||
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
expect(output).toBe(false);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false if room clipboardData files and types are empty", () => {
|
||||
@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => {
|
||||
type: "paste",
|
||||
clipboardData: { files: [], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
expect(output).toBe(false);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles event and calls sendContentListToRoom when data files are present", () => {
|
||||
@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => {
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
||||
const mockReplyToEvent = {} as unknown as MatrixEvent;
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
undefined, // this is the event relation, an optional arg
|
||||
mockReplyToEvent,
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentListToRoom with eventRelation when present", () => {
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const mockReplyToEvent = {} as unknown as MatrixEvent;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
originalEvent.clipboardData?.files,
|
||||
mockRoom.roomId,
|
||||
mockEventRelation, // this is the event relation, an optional arg
|
||||
mockReplyToEvent,
|
||||
mockClient,
|
||||
mockRoomState.timelineRenderingType,
|
||||
);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData);
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls the error handler when sentContentListToRoom errors", async () => {
|
||||
const mockErrorMessage = "something went wrong";
|
||||
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage));
|
||||
mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage));
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
clipboardData: { files: ["something here"], types: [] },
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
});
|
||||
@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => {
|
||||
getData: jest.fn().mockReturnValue("<div>invalid html"),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
|
||||
expect(output).toBe(false);
|
||||
});
|
||||
@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => {
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
|
||||
expect(fetchMock).toHaveFetchedTimes(1, "blob:");
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls error handler when fetch fails", async () => {
|
||||
@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => {
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => {
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendContentToRoom when parsing is successful", async () => {
|
||||
it("calls initiateViaInputFiles when parsing is successful", async () => {
|
||||
fetchMock.get("test/file", {
|
||||
blob: () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => {
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendContentToRoomSpy).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
mockRoom.roomId,
|
||||
mockEventRelation,
|
||||
mockClient,
|
||||
mockRoomState.replyToEvent,
|
||||
);
|
||||
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]);
|
||||
});
|
||||
expect(output).toBe(true);
|
||||
});
|
||||
@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => {
|
||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||
},
|
||||
});
|
||||
const mockErrorMessage = "sendContentToRoom failed";
|
||||
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage);
|
||||
const mockErrorMessage = "initiateViaInputFiles failed";
|
||||
mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage);
|
||||
|
||||
const originalEvent = createMockClipboardEvent({
|
||||
type: "paste",
|
||||
@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => {
|
||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||
},
|
||||
});
|
||||
const mockEventRelation = {} as unknown as IEventRelation;
|
||||
const output = handleClipboardEvent(
|
||||
originalEvent,
|
||||
originalEvent.clipboardData,
|
||||
mockRoomState,
|
||||
mockClient,
|
||||
mockEventRelation,
|
||||
);
|
||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
174
apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 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 IEventRelation, type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { MockedObject } from "jest-mock";
|
||||
import { RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel";
|
||||
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
||||
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||
import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
|
||||
import ContentMessages from "../../../src/ContentMessages";
|
||||
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
||||
|
||||
describe("RoomUploadViewModel", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let room: MockedObject<Room>;
|
||||
let dis: MockedObject<MatrixDispatcher>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkStubRoom("!room", undefined, undefined) as MockedObject<Room>;
|
||||
dis = {
|
||||
dispatch: jest.fn(),
|
||||
} as Partial<MatrixDispatcher> as MockedObject<MatrixDispatcher>;
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([true, false])("handles state of mayUpload when room.maySendMessage = %s", (maySendMessage) => {
|
||||
room.maySendMessage.mockReturnValue(maySendMessage);
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Room,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
expect(vm.getSnapshot().mayUpload).toEqual(maySendMessage);
|
||||
room.maySendMessage.mockReturnValue(!maySendMessage);
|
||||
room.emit(RoomEvent.CurrentStateUpdated, room, null as any, null as any);
|
||||
expect(vm.getSnapshot().mayUpload).toEqual(!maySendMessage);
|
||||
});
|
||||
|
||||
describe("uploads via input", () => {
|
||||
it("redirected if guest", async () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Room,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
await vm.initiateViaInputFiles([] as unknown as FileList);
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" });
|
||||
});
|
||||
it("skips empty files", async () => {
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Room,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
await vm.initiateViaInputFiles([] as unknown as FileList);
|
||||
expect(dis.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uploads with correct context", async () => {
|
||||
sendContentListToRoomSpy.mockResolvedValue(undefined);
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Thread,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} });
|
||||
vm.setReplyToEvent(replyEvent);
|
||||
const threadRelation: IEventRelation = { key: "foo" };
|
||||
vm.setThreadRelation(threadRelation);
|
||||
const fileList = [
|
||||
{
|
||||
name: "fake.png",
|
||||
size: 1024,
|
||||
type: "image/png",
|
||||
},
|
||||
] as unknown as FileList;
|
||||
await vm.initiateViaInputFiles(fileList);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
fileList,
|
||||
room.roomId,
|
||||
threadRelation,
|
||||
replyEvent,
|
||||
client,
|
||||
TimelineRenderingType.Thread,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploads via data transfer", () => {
|
||||
it("redirected if guest", async () => {
|
||||
client.isGuest.mockReturnValue(true);
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Room,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
await vm.initiateViaDataTransfer({} as DataTransfer);
|
||||
expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" });
|
||||
});
|
||||
it("skips empty files", async () => {
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Room,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
await vm.initiateViaDataTransfer({ files: [] as unknown as FileList } as DataTransfer);
|
||||
expect(dis.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uploads with correct context", async () => {
|
||||
sendContentListToRoomSpy.mockResolvedValue(undefined);
|
||||
const vm = new RoomUploadViewModel(
|
||||
room,
|
||||
client,
|
||||
TimelineRenderingType.Thread,
|
||||
dis,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
);
|
||||
const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} });
|
||||
vm.setReplyToEvent(replyEvent);
|
||||
const threadRelation: IEventRelation = { key: "foo" };
|
||||
vm.setThreadRelation(threadRelation);
|
||||
const files = [
|
||||
{
|
||||
name: "fake.png",
|
||||
size: 1024,
|
||||
type: "image/png",
|
||||
},
|
||||
] as unknown as FileList;
|
||||
await vm.initiateViaDataTransfer({ files } as DataTransfer);
|
||||
expect(sendContentListToRoomSpy).toHaveBeenCalledWith(
|
||||
files,
|
||||
room.roomId,
|
||||
threadRelation,
|
||||
replyEvent,
|
||||
client,
|
||||
TimelineRenderingType.Thread,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
@ -41,6 +41,9 @@
|
||||
"preferences": "Preferences",
|
||||
"state_encryption_enabled": "Experimental state encryption enabled"
|
||||
},
|
||||
"devtools": {
|
||||
"toggle_event": "toggle event"
|
||||
},
|
||||
"keyboard": {
|
||||
"shift": "Shift"
|
||||
},
|
||||
@ -226,6 +229,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",
|
||||
|
||||
@ -27,6 +27,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";
|
||||
@ -41,8 +42,10 @@ 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";
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
};
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||