Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/monorepo-playwright-common
# Conflicts: # pnpm-lock.yaml
7
.github/CODEOWNERS
vendored
@ -4,12 +4,13 @@
|
||||
/pnpm-lock.yaml @element-hq/element-web-team
|
||||
|
||||
/apps/web/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/unit-tests/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/unit-tests/async-components/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/unit-tests/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/unit-tests/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
/apps/web/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers
|
||||
|
||||
@ -31,7 +31,9 @@ runs:
|
||||
|
||||
- name: Move webapp to out-file-path
|
||||
shell: bash
|
||||
run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp ${{ inputs.out-file-path }}
|
||||
run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp "$OUT_PATH"
|
||||
env:
|
||||
OUT_PATH: ${{ inputs.out-file-path }}
|
||||
|
||||
- name: Clean up temp directory
|
||||
shell: bash
|
||||
|
||||
4
.github/workflows/build-and-test.yaml
vendored
@ -18,8 +18,6 @@ on:
|
||||
push:
|
||||
# We do not build on push to develop as the merge_group check handles that
|
||||
branches: [staging, master]
|
||||
repository_dispatch:
|
||||
types: [element-web-notify]
|
||||
|
||||
# support triggering from other workflows
|
||||
workflow_call:
|
||||
@ -188,6 +186,7 @@ jobs:
|
||||
uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
webapp-artifact: webapp
|
||||
reporter: blob
|
||||
|
||||
prepare_ed:
|
||||
name: "Prepare Element Desktop"
|
||||
@ -246,6 +245,7 @@ jobs:
|
||||
needs:
|
||||
- playwright_ew
|
||||
- downstream-modules
|
||||
- prepare_ed
|
||||
- build_ed_windows
|
||||
- build_ed_linux
|
||||
- build_ed_macos
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
# It uploads the received images and diffs to netlify, printing the URLs to the console
|
||||
name: Upload Shared Component Visual Test Diffs
|
||||
on:
|
||||
workflow_run:
|
||||
# Privilege escalation necessary to deploy to Netlify
|
||||
# 🚨 We must not execute any checked out code here.
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers]
|
||||
workflows: ["Shared Component Visual Tests"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
@ -1,6 +1,8 @@
|
||||
name: SonarQube
|
||||
on:
|
||||
workflow_run:
|
||||
# Privilege escalation necessary to call upon SonarCloud
|
||||
# 🚨 We must not execute any checked out code here.
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers]
|
||||
workflows: ["Tests"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
2
.github/workflows/static_analysis.yaml
vendored
@ -5,8 +5,6 @@ on:
|
||||
branches: [develop, master]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
repository_dispatch:
|
||||
types: [element-web-notify]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@ -5,8 +5,6 @@ on:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
repository_dispatch:
|
||||
types: [element-web-notify]
|
||||
workflow_call:
|
||||
inputs:
|
||||
disable_coverage:
|
||||
|
||||
@ -43,11 +43,7 @@ test.describe("Key storage out of sync toast", () => {
|
||||
});
|
||||
|
||||
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
|
||||
// We need to wait for there to be two toasts as the wait below won't work in isolation:
|
||||
// playwright only evaluates the 'first()' call initially, not subsequent times it checks, so
|
||||
// it would always be checking the same toast, even if another one is now the first.
|
||||
await expect(page.getByRole("alert")).toHaveCount(2);
|
||||
await expect(page.getByRole("alert").first()).toMatchScreenshot(
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Your key storage is out of sync." })).toMatchScreenshot(
|
||||
"key-storage-out-of-sync-toast.png",
|
||||
screenshotOptions,
|
||||
);
|
||||
|
||||
@ -328,11 +328,11 @@ test.describe("Room list", () => {
|
||||
|
||||
const roomListView = getRoomList(page);
|
||||
const videoRoom = roomListView.getByRole("option", { name: "video room" });
|
||||
await expect(videoRoom).toHaveAttribute("aria-selected", "true"); // wait for room list update
|
||||
|
||||
// focus the user menu to avoid to have hover decoration
|
||||
await page.getByRole("button", { name: "User menu" }).focus();
|
||||
|
||||
await expect(videoRoom).toBeVisible();
|
||||
await expect(videoRoom).toMatchScreenshot("room-list-item-video.png");
|
||||
});
|
||||
});
|
||||
|
||||
@ -48,9 +48,9 @@ test.describe("Room Directory", () => {
|
||||
await app.closeDialog();
|
||||
|
||||
const resp = await bot.publicRooms({});
|
||||
expect(resp.total_room_count_estimate).toEqual(1);
|
||||
expect(resp.chunk).toHaveLength(1);
|
||||
expect(resp.chunk[0].room_id).toEqual(roomId);
|
||||
expect(resp.total_room_count_estimate).toBeGreaterThanOrEqual(1);
|
||||
expect(resp.chunk).toHaveLength(resp.total_room_count_estimate);
|
||||
expect(resp.chunk.find((r) => r.room_id === roomId)).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 268 KiB |
@ -70,6 +70,7 @@
|
||||
@import "./structures/_MatrixChat.pcss";
|
||||
@import "./structures/_MessagePanel.pcss";
|
||||
@import "./structures/_NonUrgentToastContainer.pcss";
|
||||
@import "./structures/_PictureInPictureDragger.pcss";
|
||||
@import "./structures/_QuickSettingsButton.pcss";
|
||||
@import "./structures/_RightPanel.pcss";
|
||||
@import "./structures/_RoomSearch.pcss";
|
||||
@ -375,7 +376,6 @@
|
||||
@import "./views/voip/_DialPad.pcss";
|
||||
@import "./views/voip/_DialPadContextMenu.pcss";
|
||||
@import "./views/voip/_DialPadModal.pcss";
|
||||
@import "./views/voip/_LegacyCallPreview.pcss";
|
||||
@import "./views/voip/_LegacyCallView.pcss";
|
||||
@import "./views/voip/_LegacyCallViewForRoom.pcss";
|
||||
@import "./views/voip/_LegacyCallViewHeader.pcss";
|
||||
|
||||
20
apps/web/res/css/structures/_PictureInPictureDragger.pcss
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.mx_PictureInPictureDragger {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
/* Display above any widget elements */
|
||||
z-index: 102;
|
||||
}
|
||||
|
||||
.mx_PictureInPictureDragger:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@ -1378,6 +1378,10 @@ $left-gutter: 64px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mx_EventTile_annotatedInline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mx_EventTile_footer {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-2x);
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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_LegacyCallPreview {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-16;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
/* Display above any widget elements */
|
||||
z-index: 102;
|
||||
|
||||
.mx_VideoFeed_remote.mx_VideoFeed_voice {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.mx_VideoFeed_local {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@ -7,9 +7,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { EventType, type MatrixClient, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix";
|
||||
import { EventType, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MultiInviter, { type CompletionStates, type MultiInviterOptions } from "./utils/MultiInviter";
|
||||
import type MultiInviter from "./utils/MultiInviter";
|
||||
import { type CompletionStates } from "./utils/MultiInviter";
|
||||
import Modal from "./Modal";
|
||||
import { _t } from "./languageHandler";
|
||||
import InviteDialog from "./components/views/dialogs/InviteDialog";
|
||||
@ -19,33 +20,6 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
import { InviteKind } from "./components/views/dialogs/InviteDialogTypes";
|
||||
import { type Member } from "./utils/direct-messages";
|
||||
|
||||
export interface IInviteResult {
|
||||
states: CompletionStates;
|
||||
inviter: MultiInviter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites multiple addresses to a room.
|
||||
*
|
||||
* Simpler interface to {@link MultiInviter}.
|
||||
*
|
||||
* Any failures are returned via the `states` in the result.
|
||||
*
|
||||
* @param {string} roomId The ID of the room to invite to
|
||||
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
||||
* @param options Options object.
|
||||
* @returns {Promise} Promise
|
||||
*/
|
||||
export async function inviteMultipleToRoom(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
addresses: string[],
|
||||
options: MultiInviterOptions = {},
|
||||
): Promise<IInviteResult> {
|
||||
const inviter = new MultiInviter(client, roomId, options);
|
||||
return { states: await inviter.invite(addresses), inviter };
|
||||
}
|
||||
|
||||
export function showStartChatInviteDialog(initialText = ""): void {
|
||||
// This dialog handles the room creation internally - we don't need to worry about it.
|
||||
Modal.createDialog(
|
||||
|
||||
@ -103,10 +103,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||
|
||||
private async makeRecorder(): Promise<void> {
|
||||
try {
|
||||
const requestedDeviceId = MediaDeviceHandler.getAudioInput();
|
||||
const deviceIdConstraint =
|
||||
requestedDeviceId && requestedDeviceId !== "default" ? { deviceId: { exact: requestedDeviceId } } : {};
|
||||
|
||||
this.recorderStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: CHANNELS,
|
||||
deviceId: MediaDeviceHandler.getAudioInput(),
|
||||
...deviceIdConstraint,
|
||||
autoGainControl: { ideal: MediaDeviceHandler.getAudioAutoGainControl() },
|
||||
echoCancellation: { ideal: MediaDeviceHandler.getAudioEchoCancellation() },
|
||||
noiseSuppression: { ideal: MediaDeviceHandler.getAudioNoiseSuppression() },
|
||||
|
||||
@ -37,9 +37,7 @@ interface IChildrenOptions {
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
children: Array<CreatePipChildren>;
|
||||
draggable: boolean;
|
||||
onDoubleClick?: () => void;
|
||||
onMove?: () => void;
|
||||
}
|
||||
@ -181,9 +179,6 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
||||
};
|
||||
|
||||
private onStartMoving = (event: React.MouseEvent | MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseHeld = true;
|
||||
this.startingPositionX = event.clientX;
|
||||
this.startingPositionY = event.clientY;
|
||||
@ -217,9 +212,6 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
||||
private onEndMoving = (event: MouseEvent): void => {
|
||||
if (!this.mouseHeld) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseHeld = false;
|
||||
// Delaying this to the next event loop tick is necessary for click
|
||||
// event cancellation to work
|
||||
@ -250,7 +242,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={this.props.className}
|
||||
className="mx_PictureInPictureDragger"
|
||||
style={style}
|
||||
ref={this.callViewWrapper}
|
||||
onClickCapture={this.onClickCapture}
|
||||
|
||||
@ -266,12 +266,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
|
||||
|
||||
if (pipContent.length) {
|
||||
return (
|
||||
<PictureInPictureDragger
|
||||
className="mx_LegacyCallPreview"
|
||||
draggable={pipMode}
|
||||
onDoubleClick={this.onDoubleClick}
|
||||
onMove={this.onMove}
|
||||
>
|
||||
<PictureInPictureDragger onDoubleClick={this.onDoubleClick} onMove={this.onMove}>
|
||||
{pipContent}
|
||||
</PictureInPictureDragger>
|
||||
);
|
||||
|
||||
@ -1380,12 +1380,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
|
||||
|
||||
if (ev.getType() === "org.matrix.room.preview_urls") {
|
||||
this.updatePreviewUrlVisibility(room);
|
||||
this.updatePreviewUrlVisibility();
|
||||
}
|
||||
|
||||
if (ev.getType() === "m.room.encryption") {
|
||||
this.updateE2EStatus(room);
|
||||
this.updatePreviewUrlVisibility(room);
|
||||
this.updatePreviewUrlVisibility();
|
||||
}
|
||||
|
||||
// ignore anything but real-time updates at the end of the room:
|
||||
@ -1541,15 +1541,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
private updatePreviewUrlVisibility(room: Room): void {
|
||||
private updatePreviewUrlVisibility(): void {
|
||||
this.setState(({ isRoomEncrypted }) => ({
|
||||
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
|
||||
showUrlPreview: this.getPreviewUrlVisibility(isRoomEncrypted),
|
||||
}));
|
||||
}
|
||||
|
||||
private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean {
|
||||
const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled";
|
||||
return SettingsStore.getValue(key, roomId);
|
||||
private getPreviewUrlVisibility(isRoomEncrypted: boolean | null): boolean {
|
||||
return SettingsStore.getValue(isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled");
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
@ -1608,9 +1607,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
}
|
||||
|
||||
private onUrlPreviewsEnabledChange = (): void => {
|
||||
if (this.state.room) {
|
||||
this.updatePreviewUrlVisibility(this.state.room);
|
||||
}
|
||||
this.updatePreviewUrlVisibility();
|
||||
};
|
||||
|
||||
private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise<void> => {
|
||||
@ -1638,7 +1635,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
this.setState({
|
||||
isRoomEncrypted,
|
||||
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
|
||||
showUrlPreview: this.getPreviewUrlVisibility(isRoomEncrypted),
|
||||
...(newE2EStatus && { e2eStatus: newE2EStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ import { useFeatureEnabled } from "../../hooks/useSettings";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import { _t } from "../../languageHandler";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
|
||||
import { showRoomInviteDialog } from "../../RoomInvite";
|
||||
import { UIComponent } from "../../settings/UIFeature";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
@ -76,6 +76,7 @@ import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
|
||||
import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import SpacePillButton from "./SpacePillButton.tsx";
|
||||
import { useRoomName } from "../../hooks/useRoomName.ts";
|
||||
import MultiInviter from "../../utils/MultiInviter.ts";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
@ -538,11 +539,12 @@ const SpaceSetupPrivateInvite: React.FC<{
|
||||
setBusy(true);
|
||||
const targetIds = emailAddresses.map((name) => name.trim()).filter(Boolean);
|
||||
try {
|
||||
const result = await inviteMultipleToRoom(space.client, space.roomId, targetIds);
|
||||
const inviter = new MultiInviter(space.client, space.roomId);
|
||||
const states = await inviter.invite(targetIds);
|
||||
|
||||
const failedUsers = Object.keys(result.states).filter((a) => result.states[a] === "error");
|
||||
const failedUsers = Object.keys(states).filter((a) => states[a] === "error");
|
||||
if (failedUsers.length > 0) {
|
||||
logger.log("Failed to invite users to space: ", result);
|
||||
logger.log("Failed to invite users to space:", states);
|
||||
setError(
|
||||
_t("create_space|failed_invite_users", {
|
||||
csvUsers: failedUsers.join(", "),
|
||||
|
||||
@ -25,7 +25,7 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../.
|
||||
import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
|
||||
import { abbreviateUrl } from "../../../utils/UrlUtils";
|
||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite";
|
||||
import { showAnyInviteErrors } from "../../../RoomInvite";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore";
|
||||
@ -63,6 +63,7 @@ import { type NonEmptyArray } from "../../../@types/common";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
|
||||
import InviteProgressBody from "./InviteProgressBody.tsx";
|
||||
import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts";
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
@ -409,10 +410,14 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
.map((member) => ({ userId: member.userId, user: toMember(member) }));
|
||||
}
|
||||
|
||||
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
|
||||
private shouldAbortAfterInviteError(
|
||||
states: MultiInviterCompletionStates,
|
||||
inviter: MultiInviter,
|
||||
room: Room,
|
||||
): boolean {
|
||||
this.setState({ busy: false });
|
||||
const userMap = new Map<string, Member>(this.state.targets.map((member) => [member.userId, member]));
|
||||
return !showAnyInviteErrors(result.states, room, result.inviter, userMap);
|
||||
return !showAnyInviteErrors(states, room, inviter, userMap);
|
||||
}
|
||||
|
||||
private convertFilter(): Member[] {
|
||||
@ -483,11 +488,12 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds, {
|
||||
const inviter = new MultiInviter(cli, this.props.roomId, {
|
||||
// We show our own progress body, so don't pop up a separate dialog.
|
||||
inhibitProgressDialog: true,
|
||||
});
|
||||
if (!this.shouldAbortAfterInviteError(result, room)) {
|
||||
const states = await inviter.invite(targetIds);
|
||||
if (!this.shouldAbortAfterInviteError(states, inviter, room)) {
|
||||
// handles setting error message too
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ interface IProps {
|
||||
label?: string;
|
||||
isExplicit?: boolean;
|
||||
hideIfCannotSet?: boolean;
|
||||
requires?: BooleanSettingKey[];
|
||||
onChange?(checked: boolean): void;
|
||||
}
|
||||
|
||||
@ -45,6 +46,12 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
|
||||
|
||||
public componentDidMount(): void {
|
||||
defaultWatchManager.watchSetting(this.props.name, this.props.roomId ?? null, this.onSettingChange);
|
||||
if (this.props.requires) {
|
||||
// If we have any dependencies for this feature, also watch those features to ensure we catch the disabled state.
|
||||
for (const flag of this.props.requires) {
|
||||
defaultWatchManager.watchSetting(flag, this.props.roomId ?? null, this.onSettingChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
|
||||
@ -301,6 +301,9 @@ class InnerTextualBody extends React.Component<Props> {
|
||||
const isCaption = [MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(
|
||||
content.msgtype as MsgType,
|
||||
);
|
||||
const annotatedClassName = isEmote
|
||||
? "mx_EventTile_annotated mx_EventTile_annotatedInline"
|
||||
: "mx_EventTile_annotated";
|
||||
|
||||
const willHaveWrapper =
|
||||
this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote;
|
||||
@ -315,7 +318,7 @@ class InnerTextualBody extends React.Component<Props> {
|
||||
|
||||
if (this.props.replacingEventId) {
|
||||
body = (
|
||||
<div dir="auto" className="mx_EventTile_annotated">
|
||||
<div dir="auto" className={annotatedClassName}>
|
||||
{body}
|
||||
{this.renderEditedMarker()}
|
||||
</div>
|
||||
@ -323,7 +326,7 @@ class InnerTextualBody extends React.Component<Props> {
|
||||
}
|
||||
if (this.props.isSeeingThroughMessageHiddenForModeration) {
|
||||
body = (
|
||||
<div dir="auto" className="mx_EventTile_annotated">
|
||||
<div dir="auto" className={annotatedClassName}>
|
||||
{body}
|
||||
{this.renderPendingModerationMarker()}
|
||||
</div>
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2017 Travis Ralston
|
||||
Copyright 2016 OpenMarket 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 ReactNode, type JSX } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
import SettingsFieldset from "../settings/SettingsFieldset";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||
import { useSettingValueAt } from "../../../hooks/useSettings.ts";
|
||||
|
||||
/**
|
||||
* The URL preview settings for a room
|
||||
*/
|
||||
interface UrlPreviewSettingsProps {
|
||||
/**
|
||||
* The room.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export function UrlPreviewSettings({ room }: UrlPreviewSettingsProps): JSX.Element {
|
||||
const { roomId } = room;
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const isEncrypted = useIsEncrypted(matrixClient, room);
|
||||
const isLoading = isEncrypted === null;
|
||||
|
||||
return (
|
||||
<SettingsFieldset
|
||||
legend={_t("room_settings|general|url_previews_section")}
|
||||
description={!isLoading && <Description isEncrypted={isEncrypted} />}
|
||||
>
|
||||
{isLoading ? (
|
||||
<InlineSpinner />
|
||||
) : (
|
||||
<>
|
||||
<PreviewsForRoom isEncrypted={isEncrypted} roomId={roomId} />
|
||||
<SettingsFlag
|
||||
name={isEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"}
|
||||
level={SettingLevel.ROOM_DEVICE}
|
||||
roomId={roomId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsFieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for the user settings link
|
||||
* @param e
|
||||
*/
|
||||
function onClickUserSettings(e: ButtonEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* The description for the URL preview settings
|
||||
*/
|
||||
interface DescriptionProps {
|
||||
/**
|
||||
* Whether the room is encrypted
|
||||
*/
|
||||
isEncrypted: boolean;
|
||||
}
|
||||
|
||||
function Description({ isEncrypted }: DescriptionProps): JSX.Element {
|
||||
const urlPreviewsEnabled = useSettingValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
|
||||
|
||||
let previewsForAccount: ReactNode | undefined;
|
||||
if (isEncrypted) {
|
||||
previewsForAccount = _t("room_settings|general|url_preview_encryption_warning");
|
||||
} else {
|
||||
const button = {
|
||||
a: (sub: string) => (
|
||||
<AccessibleButton kind="link_inline" onClick={onClickUserSettings}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
};
|
||||
|
||||
previewsForAccount = urlPreviewsEnabled
|
||||
? _t("room_settings|general|user_url_previews_default_on", {}, button)
|
||||
: _t("room_settings|general|user_url_previews_default_off", {}, button);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{_t("room_settings|general|url_preview_explainer")}</p>
|
||||
<p>{previewsForAccount}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The description for the URL preview settings
|
||||
*/
|
||||
interface PreviewsForRoomProps {
|
||||
/**
|
||||
* Whether the room is encrypted
|
||||
*/
|
||||
isEncrypted: boolean;
|
||||
/**
|
||||
* The room ID
|
||||
*/
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function PreviewsForRoom({ isEncrypted, roomId }: PreviewsForRoomProps): JSX.Element | null {
|
||||
const urlPreviewsEnabled = useSettingValueAt(
|
||||
SettingLevel.ACCOUNT,
|
||||
"urlPreviewsEnabled",
|
||||
roomId,
|
||||
/*explicit=*/ true,
|
||||
);
|
||||
if (isEncrypted) return null;
|
||||
|
||||
let previewsForRoom: ReactNode;
|
||||
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
|
||||
previewsForRoom = (
|
||||
<SettingsFlag name="urlPreviewsEnabled" level={SettingLevel.ROOM} roomId={roomId} isExplicit={true} />
|
||||
);
|
||||
} else {
|
||||
previewsForRoom = (
|
||||
<div>
|
||||
{urlPreviewsEnabled
|
||||
? _t("room_settings|general|default_url_previews_on")
|
||||
: _t("room_settings|general|default_url_previews_off")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return previewsForRoom;
|
||||
}
|
||||
@ -15,14 +15,11 @@ import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
|
||||
import AccessibleButton, { type ButtonEvent } from "../../../elements/AccessibleButton";
|
||||
import dis from "../../../../../dispatcher/dispatcher";
|
||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../../settings/UIFeature";
|
||||
import AliasSettings from "../../../room_settings/AliasSettings";
|
||||
import PosthogTrackers from "../../../../../PosthogTrackers";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings";
|
||||
import { MediaPreviewAccountSettings } from "../user/MediaPreviewAccountSettings";
|
||||
|
||||
interface IProps {
|
||||
@ -62,10 +59,6 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
|
||||
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
|
||||
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", "") ?? undefined;
|
||||
|
||||
const urlPreviewSettings = SettingsStore.getValue(UIFeature.URLPreviews) ? (
|
||||
<UrlPreviewSettings room={room} />
|
||||
) : null;
|
||||
|
||||
let leaveSection;
|
||||
if (room.getMyMembership() === KnownMembership.Join) {
|
||||
leaveSection = (
|
||||
@ -99,7 +92,6 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection heading={_t("room_settings|general|other_section")}>
|
||||
{urlPreviewSettings}
|
||||
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
|
||||
<MediaPreviewAccountSettings roomId={room.roomId} />
|
||||
</SettingsSubsection>
|
||||
|
||||
@ -147,11 +147,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
|
||||
"showCodeLineNumbers",
|
||||
];
|
||||
|
||||
private static IMAGES_AND_VIDEOS_SETTINGS: BooleanSettingKey[] = [
|
||||
"urlPreviewsEnabled",
|
||||
"autoplayGifs",
|
||||
"autoplayVideo",
|
||||
];
|
||||
private static IMAGES_AND_VIDEOS_SETTINGS: BooleanSettingKey[] = ["autoplayGifs", "autoplayVideo"];
|
||||
|
||||
private static TIMELINE_SETTINGS: BooleanSettingKey[] = [
|
||||
"showTypingNotifications",
|
||||
@ -350,6 +346,19 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
|
||||
{this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)}
|
||||
</SettingsSubsection>
|
||||
|
||||
<SettingsSubsection
|
||||
heading={_t("settings|preferences|link_previews_heading")}
|
||||
description={_t("settings|preferences|link_previews_description")}
|
||||
formWrap
|
||||
>
|
||||
<SettingsFlag name="urlPreviewsEnabled" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag
|
||||
name="urlPreviewsEnabled_e2ee"
|
||||
level={SettingLevel.DEVICE}
|
||||
requires={["urlPreviewsEnabled"]}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
|
||||
<SettingsSubsection heading={_t("settings|preferences|media_heading")} formWrap>
|
||||
{this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)}
|
||||
</SettingsSubsection>
|
||||
|
||||
@ -228,21 +228,25 @@ export class DeviceListenerCurrentDevice {
|
||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
}
|
||||
} else {
|
||||
// If we get here, then we are verified, have key backup, and
|
||||
// 4S, but allSystemsReady is false, which means that either
|
||||
// secretStorageStatus.ready is false (which means that 4S
|
||||
// doesn't have all the secrets), or we don't have the backup
|
||||
// key cached locally. If any of the cross-signing keys are
|
||||
// missing locally, that is handled by the
|
||||
// `!allCrossSigningSecretsCached` branch above.
|
||||
logSpan.warn("4S is missing secrets or backup key not cached", {
|
||||
} else if (!recoveryIsOk) {
|
||||
logSpan.warn("4S is missing secrets: setting state to KEY_STORAGE_OUT_OF_SYNC", {
|
||||
secretStorageStatus,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
keyBackupDownloadIsOk,
|
||||
});
|
||||
await this.setDeviceState("key_storage_out_of_sync", logSpan);
|
||||
} else if (!keyBackupDownloadIsOk) {
|
||||
logSpan.warn("Backup key is not cached locally: setting state to KEY_STORAGE_OUT_OF_SYNC", {
|
||||
secretStorageStatus,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
keyBackupDownloadIsOk,
|
||||
});
|
||||
await this.setDeviceState("key_storage_out_of_sync", logSpan);
|
||||
} else {
|
||||
// We should not get here
|
||||
logSpan.error("DeviceListenerCurrentDevice: allSystemsReady was false, but no case matched.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2238,8 +2238,6 @@
|
||||
"aliases_section": "Room Addresses",
|
||||
"avatar_field_label": "Room avatar",
|
||||
"canonical_alias_field_label": "Main address",
|
||||
"default_url_previews_off": "URL previews are disabled by default for participants in this room.",
|
||||
"default_url_previews_on": "URL previews are enabled by default for participants in this room.",
|
||||
"description_space": "Edit settings relating to your space.",
|
||||
"error_creating_alias_description": "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.",
|
||||
"error_creating_alias_title": "Error creating address",
|
||||
@ -2270,12 +2268,7 @@
|
||||
"published_aliases_explainer_space": "Published addresses can be used by anyone on any server to join your space.",
|
||||
"published_aliases_section": "Published Addresses",
|
||||
"save": "Save Changes",
|
||||
"topic_field_label": "Room Topic",
|
||||
"url_preview_encryption_warning": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
|
||||
"url_preview_explainer": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.",
|
||||
"url_previews_section": "URL Previews",
|
||||
"user_url_previews_default_off": "You have <a>disabled</a> URL previews by default.",
|
||||
"user_url_previews_default_on": "You have <a>enabled</a> URL previews by default."
|
||||
"topic_field_label": "Room Topic"
|
||||
},
|
||||
"notifications": {
|
||||
"browse_button": "Browse",
|
||||
@ -2697,9 +2690,8 @@
|
||||
"unable_to_load_msisdns": "Unable to load phone numbers",
|
||||
"username": "Username"
|
||||
},
|
||||
"inline_url_previews_default": "Enable inline URL previews by default",
|
||||
"inline_url_previews_room": "Enable URL previews by default for participants in this room",
|
||||
"inline_url_previews_room_account": "Enable URL previews for this room (only affects you)",
|
||||
"inline_url_previews_default": "Enable previews",
|
||||
"inline_url_previews_encrypted": "Enable previews in encrypted rooms",
|
||||
"insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message",
|
||||
"invite_controls": {
|
||||
"default_label": "Allow users to invite you to rooms"
|
||||
@ -2837,6 +2829,8 @@
|
||||
"enable_tray_icon": "Show tray icon and minimise window to it on close",
|
||||
"keyboard_heading": "Keyboard shortcuts",
|
||||
"keyboard_view_shortcuts_button": "To view all keyboard shortcuts, <a>click here</a>.",
|
||||
"link_previews_description": "Shows information about links underneath messages",
|
||||
"link_previews_heading": "Link previews",
|
||||
"media_heading": "Images, GIFs and videos",
|
||||
"presence_description": "Share your activity and status with others.",
|
||||
"publish_timezone": "Publish timezone on public profile",
|
||||
@ -4003,6 +3997,7 @@
|
||||
"change_name_this_room": "Change the name of this room",
|
||||
"change_topic_active_room": "Change the topic of your active room",
|
||||
"change_topic_this_room": "Change the topic of this room",
|
||||
"download_file": "Download files from the media repository",
|
||||
"receive_membership_active_room": "See when people join, leave, or are invited to your active room",
|
||||
"receive_membership_this_room": "See when people join, leave, or are invited to this room",
|
||||
"remove_ban_invite_leave_active_room": "Remove, ban, or invite people to your active room, and make you leave",
|
||||
|
||||
@ -51,6 +51,7 @@ import MediaPreviewConfigController from "./controllers/MediaPreviewConfigContro
|
||||
import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts";
|
||||
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
|
||||
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
|
||||
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
|
||||
|
||||
export const defaultWatchManager = new WatchManager();
|
||||
|
||||
@ -1140,22 +1141,22 @@ export const SETTINGS: Settings = {
|
||||
controller: new UIFeatureController(UIFeature.AdvancedEncryption),
|
||||
},
|
||||
"urlPreviewsEnabled": {
|
||||
supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
|
||||
displayName: {
|
||||
"default": _td("settings|inline_url_previews_default"),
|
||||
"room-account": _td("settings|inline_url_previews_room_account"),
|
||||
"room": _td("settings|inline_url_previews_room"),
|
||||
},
|
||||
// Enabled by default and client configurable as this setting only allows unencrypted
|
||||
// messages to be previewed.
|
||||
supportedLevels: [SettingLevel.DEVICE, SettingLevel.ACCOUNT, SettingLevel.CONFIG],
|
||||
supportedLevelsAreOrdered: true,
|
||||
displayName: _td("settings|inline_url_previews_default"),
|
||||
default: true,
|
||||
controller: new UIFeatureController(UIFeature.URLPreviews),
|
||||
},
|
||||
"urlPreviewsEnabled_e2ee": {
|
||||
supportedLevels: [SettingLevel.ROOM_DEVICE],
|
||||
displayName: {
|
||||
"room-device": _td("settings|inline_url_previews_room_account"),
|
||||
},
|
||||
// Can only be enabled per-device to ensure neither the homeserver nor client config
|
||||
// can impact the user's choices.
|
||||
supportedLevels: [SettingLevel.DEVICE],
|
||||
supportedLevelsAreOrdered: true,
|
||||
displayName: _td("settings|inline_url_previews_encrypted"),
|
||||
default: false,
|
||||
controller: new UIFeatureController(UIFeature.URLPreviews),
|
||||
controller: new RequiresSettingsController([UIFeature.URLPreviews, "urlPreviewsEnabled"]),
|
||||
},
|
||||
"notificationsEnabled": {
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
|
||||
|
||||
@ -654,40 +654,6 @@ export default class SettingsStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the setting for URL previews in e2e rooms from room account
|
||||
* data to the room device level.
|
||||
*
|
||||
* @param isFreshLogin True if the user has just logged in, false if a previous session is being restored.
|
||||
*/
|
||||
private static async migrateURLPreviewsE2EE(isFreshLogin: boolean): Promise<void> {
|
||||
const MIGRATION_DONE_FLAG = "url_previews_e2ee_migration_done";
|
||||
if (localStorage.getItem(MIGRATION_DONE_FLAG)) return;
|
||||
if (isFreshLogin) return;
|
||||
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
|
||||
while (!client.isInitialSyncComplete()) {
|
||||
await new Promise((r) => client.once(ClientEvent.Sync, r));
|
||||
}
|
||||
|
||||
logger.info("Performing one-time settings migration of URL previews in E2EE rooms");
|
||||
|
||||
const roomAccounthandler = LEVEL_HANDLERS[SettingLevel.ROOM_ACCOUNT];
|
||||
|
||||
for (const room of client.getRooms()) {
|
||||
// We need to use the handler directly because this setting is no longer supported
|
||||
// at this level at all
|
||||
const val = roomAccounthandler.getValue("urlPreviewsEnabled_e2ee", room.roomId);
|
||||
|
||||
if (val !== undefined) {
|
||||
await SettingsStore.setValue("urlPreviewsEnabled_e2ee", room.roomId, SettingLevel.ROOM_DEVICE, val);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the setting for visible images to a setting.
|
||||
*/
|
||||
@ -739,15 +705,6 @@ export default class SettingsStore {
|
||||
* Runs or queues any setting migrations needed.
|
||||
*/
|
||||
public static runMigrations(isFreshLogin: boolean): void {
|
||||
// This can be removed once enough users have run a version of Element with
|
||||
// this migration. A couple of months after its release should be sufficient
|
||||
// (so around October 2024).
|
||||
// The consequences of missing the migration are only that URL previews will
|
||||
// be disabled in E2EE rooms.
|
||||
SettingsStore.migrateURLPreviewsE2EE(isFreshLogin).catch((e) => {
|
||||
logger.error("Failed to migrate URL previews in E2EE rooms:", e);
|
||||
});
|
||||
|
||||
// This can be removed once enough users have run a version of Element with
|
||||
// this migration.
|
||||
// The consequences of missing the migration are that previously shown images
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 SettingController from "./SettingController";
|
||||
import SettingsStore from "../SettingsStore";
|
||||
import type { BooleanSettingKey } from "../Settings.tsx";
|
||||
|
||||
/**
|
||||
* Disables a setting & forces it's value if one or more settings are not enabled
|
||||
*/
|
||||
export default class RequiresSettingsController extends SettingController {
|
||||
public constructor(
|
||||
public readonly settingNames: BooleanSettingKey[],
|
||||
private forcedValue = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public getValueOverride(): any {
|
||||
if (this.settingDisabled) {
|
||||
// per the docs: we force a disabled state when the feature isn't active
|
||||
return this.forcedValue;
|
||||
}
|
||||
return null; // no override
|
||||
}
|
||||
|
||||
public get settingDisabled(): boolean {
|
||||
return this.settingNames.some((s) => !SettingsStore.getValue(s));
|
||||
}
|
||||
}
|
||||
@ -76,15 +76,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
|
||||
};
|
||||
|
||||
public getValue(settingName: string, roomId: string): any {
|
||||
// Special case URL previews
|
||||
if (settingName === "urlPreviewsEnabled") {
|
||||
const content = this.getSettings(roomId, "org.matrix.room.preview_urls") || {};
|
||||
|
||||
// Check to make sure that we actually got a boolean
|
||||
if (typeof content["disable"] !== "boolean") return null;
|
||||
return !content["disable"];
|
||||
}
|
||||
|
||||
// Special case allowed widgets
|
||||
if (settingName === "allowedWidgets") {
|
||||
return this.getSettings(roomId, ALLOWED_WIDGETS_EVENT_TYPE);
|
||||
|
||||
@ -10,13 +10,13 @@ import { ClientEvent, EventType, type MatrixClient, type Room } from "matrix-js-
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { inviteMultipleToRoom, showAnyInviteErrors } from "../RoomInvite";
|
||||
import { showAnyInviteErrors } from "../RoomInvite";
|
||||
import Modal, { type IHandle } from "../Modal";
|
||||
import { _t } from "../languageHandler";
|
||||
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
||||
import SpaceStore from "../stores/spaces/SpaceStore";
|
||||
import Spinner from "../components/views/elements/Spinner";
|
||||
import type { MultiInviterOptions } from "./MultiInviter";
|
||||
import MultiInviter, { type MultiInviterOptions } from "./MultiInviter";
|
||||
|
||||
export interface RoomUpgradeProgress {
|
||||
roomUpgraded: boolean;
|
||||
@ -158,7 +158,8 @@ async function inviteUsersToRoom(
|
||||
userIds: string[],
|
||||
inviteOptions: MultiInviterOptions,
|
||||
): Promise<void> {
|
||||
const result = await inviteMultipleToRoom(client, roomId, userIds, inviteOptions);
|
||||
const inviter = new MultiInviter(client, roomId, inviteOptions);
|
||||
const states = await inviter.invite(userIds);
|
||||
const room = client.getRoom(roomId)!;
|
||||
showAnyInviteErrors(result.states, room, result.inviter);
|
||||
showAnyInviteErrors(states, room, inviter);
|
||||
}
|
||||
|
||||
@ -182,6 +182,16 @@ export class RoomListViewModel
|
||||
// Update roomsMap immediately before clearing VMs
|
||||
this.updateRoomsMap(this.roomsResult);
|
||||
|
||||
// When a filter is toggled on, expand sections that have results so they're visible
|
||||
if (newFilter) {
|
||||
for (const section of this.roomsResult.sections) {
|
||||
if (section.rooms.length > 0) {
|
||||
const sectionHeaderVM = this.roomSectionHeaderViewModels.get(section.tag);
|
||||
if (sectionHeaderVM) sectionHeaderVM.isExpanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateRoomListData();
|
||||
};
|
||||
|
||||
|
||||
@ -57,6 +57,9 @@ export class CapabilityText {
|
||||
[MatrixCapabilities.MSC2931Navigate]: {
|
||||
[GENERIC_WIDGET_KIND]: _td("widget|capability|switch_room_message_user"),
|
||||
},
|
||||
[MatrixCapabilities.MSC4039DownloadFile]: {
|
||||
[GENERIC_WIDGET_KIND]: _td("widget|capability|download_file"),
|
||||
},
|
||||
};
|
||||
|
||||
private static stateSendRecvCaps: SendRecvStaticCapText = {
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { getMockClientWithEventEmitter } from "../test-utils";
|
||||
import { inviteMultipleToRoom } from "../../src/RoomInvite.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("inviteMultipleToRoom", () => {
|
||||
it("can be called wth no `options`", async () => {
|
||||
const client = getMockClientWithEventEmitter({});
|
||||
const { states, inviter } = await inviteMultipleToRoom(client, "!room:id", []);
|
||||
expect(states).toEqual({});
|
||||
|
||||
// @ts-ignore reference to private property
|
||||
expect(inviter.options).toEqual({});
|
||||
});
|
||||
});
|
||||
@ -120,6 +120,29 @@ describe("VoiceRecording", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should request the selected microphone as an exact device constraint", async () => {
|
||||
MediaDeviceHandlerMock.getAudioInput.mockReturnValue("selected-mic");
|
||||
await recording.start();
|
||||
|
||||
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audio: expect.objectContaining({ deviceId: { exact: "selected-mic" } }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not force an exact microphone when default device is selected", async () => {
|
||||
MediaDeviceHandlerMock.getAudioInput.mockReturnValue("default");
|
||||
await recording.start();
|
||||
|
||||
const constraints = mocked(navigator.mediaDevices.getUserMedia).mock.calls[0][0] as MediaStreamConstraints;
|
||||
expect(constraints.audio).toEqual(
|
||||
expect.not.objectContaining({
|
||||
deviceId: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when recording", () => {
|
||||
|
||||
@ -36,7 +36,7 @@ describe("PictureInPictureDragger", () => {
|
||||
|
||||
describe("when rendering the dragger with PiP content 1", () => {
|
||||
beforeEach(() => {
|
||||
renderResult = render(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
|
||||
renderResult = render(<PictureInPictureDragger>{mkContent1}</PictureInPictureDragger>);
|
||||
});
|
||||
|
||||
it("should render the PiP content", () => {
|
||||
@ -45,7 +45,7 @@ describe("PictureInPictureDragger", () => {
|
||||
|
||||
describe("and rerendering PiP content 1", () => {
|
||||
beforeEach(() => {
|
||||
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
|
||||
renderResult.rerender(<PictureInPictureDragger>{mkContent1}</PictureInPictureDragger>);
|
||||
});
|
||||
|
||||
it("should not change the PiP content", () => {
|
||||
@ -55,7 +55,7 @@ describe("PictureInPictureDragger", () => {
|
||||
|
||||
describe("and rendering PiP content 2", () => {
|
||||
beforeEach(() => {
|
||||
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent2}</PictureInPictureDragger>);
|
||||
renderResult.rerender(<PictureInPictureDragger>{mkContent2}</PictureInPictureDragger>);
|
||||
});
|
||||
|
||||
it("should update the PiP content", () => {
|
||||
@ -66,9 +66,7 @@ describe("PictureInPictureDragger", () => {
|
||||
|
||||
describe("when rendering the dragger with PiP content 1 and 2", () => {
|
||||
beforeEach(() => {
|
||||
renderResult = render(
|
||||
<PictureInPictureDragger draggable={true}>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>,
|
||||
);
|
||||
renderResult = render(<PictureInPictureDragger>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>);
|
||||
});
|
||||
|
||||
it("should render both contents", () => {
|
||||
@ -83,7 +81,7 @@ describe("PictureInPictureDragger", () => {
|
||||
beforeEach(() => {
|
||||
clickSpy = jest.fn();
|
||||
render(
|
||||
<PictureInPictureDragger draggable={true}>
|
||||
<PictureInPictureDragger>
|
||||
{[
|
||||
({ onStartMoving }) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
class="mx_PictureInPictureDragger"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
@ -20,6 +21,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
class="mx_PictureInPictureDragger"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
@ -34,6 +36,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
class="mx_PictureInPictureDragger"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
@ -46,6 +49,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
class="mx_PictureInPictureDragger"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
|
||||
@ -146,6 +146,28 @@ describe("<TextualBody />", () => {
|
||||
expect(content).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("keeps edited emote bodies inline with the sender", () => {
|
||||
DMRoomMap.makeShared(defaultMatrixClient);
|
||||
|
||||
const ev = mkEvent({
|
||||
type: "m.room.message",
|
||||
room: room1Id,
|
||||
user: "sender",
|
||||
content: {
|
||||
body: "winks",
|
||||
msgtype: "m.emote",
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
|
||||
|
||||
const { container } = getComponent({ mxEvent: ev, replacingEventId: ev.getId() });
|
||||
|
||||
const annotated = container.querySelector(".mx_MEmoteBody > .mx_EventTile_annotatedInline");
|
||||
expect(annotated).not.toBeNull();
|
||||
expect(annotated?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("renders m.notice correctly", () => {
|
||||
DMRoomMap.makeShared(defaultMatrixClient);
|
||||
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector 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 MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
|
||||
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils";
|
||||
import { UrlPreviewSettings } from "../../../../../src/components/views/room_settings/UrlPreviewSettings.tsx";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore.ts";
|
||||
import dis from "../../../../../src/dispatcher/dispatcher.ts";
|
||||
import { Action } from "../../../../../src/dispatcher/actions.ts";
|
||||
|
||||
describe("UrlPreviewSettings", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createTestClient();
|
||||
room = mkStubRoom("roomId", "room", client);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<Form.Root>
|
||||
<UrlPreviewSettings room={room} />
|
||||
</Form.Root>,
|
||||
withClientContextRenderOptions(client),
|
||||
);
|
||||
}
|
||||
|
||||
it("should display the correct preview when the setting is in a loading state", () => {
|
||||
jest.spyOn(client, "getCrypto").mockReturnValue(undefined);
|
||||
const { asFragment } = renderComponent();
|
||||
expect(screen.getByText("URL Previews")).toBeInTheDocument();
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct preview when the room is encrypted and the url preview is enabled", async () => {
|
||||
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
|
||||
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
|
||||
|
||||
const { asFragment } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct preview when the room is unencrypted and the url preview is enabled", async () => {
|
||||
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
|
||||
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
|
||||
jest.spyOn(dis, "fire").mockReturnValue(undefined);
|
||||
|
||||
const { asFragment } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "enabled" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("URL previews are enabled by default for participants in this room."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
screen.getByRole("button", { name: "enabled" }).click();
|
||||
expect(dis.fire).toHaveBeenCalledWith(Action.ViewUserSettings);
|
||||
});
|
||||
|
||||
it("should display the correct preview when the room is unencrypted and the url preview is disabled", async () => {
|
||||
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false);
|
||||
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(false);
|
||||
|
||||
const { asFragment } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "disabled" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("URL previews are disabled by default for participants in this room."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -1,270 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`UrlPreviewSettings should display the correct preview when the room is encrypted and the url preview is enabled 1`] = `
|
||||
<DocumentFragment>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
>
|
||||
<fieldset
|
||||
class="mx_SettingsFieldset"
|
||||
>
|
||||
<legend
|
||||
class="mx_SettingsFieldset_legend"
|
||||
>
|
||||
URL Previews
|
||||
</legend>
|
||||
<div
|
||||
class="mx_SettingsFieldset_description"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
<p>
|
||||
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
|
||||
</p>
|
||||
<p>
|
||||
In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsFieldset_content"
|
||||
>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
id="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
>
|
||||
Enable URL previews for this room (only affects you)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is disabled 1`] = `
|
||||
<DocumentFragment>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
>
|
||||
<fieldset
|
||||
class="mx_SettingsFieldset"
|
||||
>
|
||||
<legend
|
||||
class="mx_SettingsFieldset_legend"
|
||||
>
|
||||
URL Previews
|
||||
</legend>
|
||||
<div
|
||||
class="mx_SettingsFieldset_description"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
<p>
|
||||
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You have
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
disabled
|
||||
</div>
|
||||
URL previews by default.
|
||||
<p />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsFieldset_content"
|
||||
>
|
||||
<div>
|
||||
URL previews are disabled by default for participants in this room.
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
>
|
||||
Enable inline URL previews by default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is enabled 1`] = `
|
||||
<DocumentFragment>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
>
|
||||
<fieldset
|
||||
class="mx_SettingsFieldset"
|
||||
>
|
||||
<legend
|
||||
class="mx_SettingsFieldset_legend"
|
||||
>
|
||||
URL Previews
|
||||
</legend>
|
||||
<div
|
||||
class="mx_SettingsFieldset_description"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
<p>
|
||||
When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You have
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
enabled
|
||||
</div>
|
||||
URL previews by default.
|
||||
<p />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsFieldset_content"
|
||||
>
|
||||
<div>
|
||||
URL previews are enabled by default for participants in this room.
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
id="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_vY7Q4uEh9K38"
|
||||
>
|
||||
Enable inline URL previews by default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewSettings should display the correct preview when the setting is in a loading state 1`] = `
|
||||
<DocumentFragment>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
>
|
||||
<fieldset
|
||||
class="mx_SettingsFieldset"
|
||||
>
|
||||
<legend
|
||||
class="mx_SettingsFieldset_legend"
|
||||
>
|
||||
URL Previews
|
||||
</legend>
|
||||
<div
|
||||
class="mx_SettingsFieldset_content"
|
||||
>
|
||||
<svg
|
||||
class="_icon_11k6c_18"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
style="width: 20px; height: 20px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -905,9 +905,18 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<h2
|
||||
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
|
||||
>
|
||||
Images, GIFs and videos
|
||||
Link previews
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsSubsection_description"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
Shows information about links underneath messages
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsSubsection_content"
|
||||
>
|
||||
@ -923,7 +932,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<input
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_vfGFMldL2r2v"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
@ -940,7 +948,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_vfGFMldL2r2v"
|
||||
>
|
||||
Enable inline URL previews by default
|
||||
Enable previews
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -955,7 +963,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_bsSwicmKUiOB"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
@ -972,10 +979,31 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_bsSwicmKUiOB"
|
||||
>
|
||||
Autoplay GIFs
|
||||
Enable previews in encrypted rooms
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsection"
|
||||
>
|
||||
<div
|
||||
class="mx_SettingsSubsectionHeading"
|
||||
>
|
||||
<h2
|
||||
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
|
||||
>
|
||||
Images, GIFs and videos
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsSubsection_content"
|
||||
>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
@ -1003,6 +1031,38 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_dvqsxEaZtl3A"
|
||||
>
|
||||
Autoplay GIFs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_NIiWzqsApP1c"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_NIiWzqsApP1c"
|
||||
>
|
||||
Autoplay videos
|
||||
</label>
|
||||
@ -1029,39 +1089,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<div
|
||||
class="mx_SettingsSubsection_content"
|
||||
>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_NIiWzqsApP1c"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_NIiWzqsApP1c"
|
||||
>
|
||||
Show typing notifications
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
@ -1091,7 +1118,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_q1SIAPqLMVXh"
|
||||
>
|
||||
Show a placeholder for removed messages
|
||||
Show typing notifications
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1124,7 +1151,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_dXFDGgBsKXay"
|
||||
>
|
||||
Show read receipts sent by other users
|
||||
Show a placeholder for removed messages
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1157,7 +1184,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_7Az0xw4Bs4Tt"
|
||||
>
|
||||
Show join/leave messages (invites/removes/bans unaffected)
|
||||
Show read receipts sent by other users
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1190,7 +1217,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_8jmzPIlPoBCv"
|
||||
>
|
||||
Show display name changes
|
||||
Show join/leave messages (invites/removes/bans unaffected)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1223,7 +1250,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_enFRaTjdsFou"
|
||||
>
|
||||
Show chat effects (animations when receiving e.g. confetti)
|
||||
Show display name changes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1256,7 +1283,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_bfwnd5rz4XNX"
|
||||
>
|
||||
Show profile picture changes
|
||||
Show chat effects (animations when receiving e.g. confetti)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1289,7 +1316,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_gs5uWEzYzZrS"
|
||||
>
|
||||
Show avatars in user, room and event mentions
|
||||
Show profile picture changes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1322,7 +1349,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_qWg7OgID1yRR"
|
||||
>
|
||||
Enable big emoji in chat
|
||||
Show avatars in user, room and event mentions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1355,7 +1382,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_pOPewl7rtMbV"
|
||||
>
|
||||
Jump to the bottom of the timeline when you send a message
|
||||
Enable big emoji in chat
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -1387,6 +1414,39 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_cmt3PZSyNp3v"
|
||||
>
|
||||
Jump to the bottom of the timeline when you send a message
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
>
|
||||
<div
|
||||
class="_container_udcm8_10"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_dJJz3lHUv9XX"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div
|
||||
class="_ui_udcm8_34"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_dJJz3lHUv9XX"
|
||||
>
|
||||
Show current profile picture and name for users in message history
|
||||
</label>
|
||||
@ -1424,7 +1484,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<input
|
||||
class="_input_udcm8_24"
|
||||
id="_r_24_"
|
||||
id="_r_26_"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
@ -1438,7 +1498,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="_r_24_"
|
||||
for="_r_26_"
|
||||
>
|
||||
Hide avatars of room and inviter
|
||||
</label>
|
||||
@ -1452,13 +1512,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_26_"
|
||||
for="radix-_r_28_"
|
||||
>
|
||||
Show media in timeline
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91 mx_MediaPreviewAccountSetting_RadioHelp"
|
||||
id="radix-_r_27_"
|
||||
id="radix-_r_29_"
|
||||
>
|
||||
A hidden media can always be shown by tapping on it
|
||||
</span>
|
||||
@ -1571,7 +1631,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="_r_2b_"
|
||||
id="_r_2d_"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
@ -1585,13 +1645,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="_r_2b_"
|
||||
for="_r_2d_"
|
||||
>
|
||||
Allow users to invite you to rooms
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_2d_"
|
||||
id="radix-_r_2f_"
|
||||
>
|
||||
Your server does not implement this feature.
|
||||
</span>
|
||||
@ -1636,7 +1696,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
<input
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_dJJz3lHUv9XX"
|
||||
id="mx_SettingsFlag_SBSSOZDRlzlA"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
@ -1650,7 +1710,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_dJJz3lHUv9XX"
|
||||
for="mx_SettingsFlag_SBSSOZDRlzlA"
|
||||
>
|
||||
Show NSFW content
|
||||
</label>
|
||||
@ -1690,7 +1750,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
checked=""
|
||||
class="_input_udcm8_24"
|
||||
disabled=""
|
||||
id="mx_SettingsFlag_SBSSOZDRlzlA"
|
||||
id="mx_SettingsFlag_FLEpLCb0jpp6"
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
@ -1704,7 +1764,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="mx_SettingsFlag_SBSSOZDRlzlA"
|
||||
for="mx_SettingsFlag_FLEpLCb0jpp6"
|
||||
>
|
||||
Prompt before sending invites to potentially invalid matrix IDs
|
||||
</label>
|
||||
|
||||
@ -96,63 +96,18 @@ describe("SettingsStore", () => {
|
||||
describe("runMigrations", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
let localStorageSetItemSpy: jest.SpyInstance;
|
||||
let localStorageSetPromise: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = mkStubRoom("!room:example.org", "Room", client);
|
||||
room.getAccountData = jest.fn().mockReturnValue({
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
urlPreviewsEnabled_e2ee: true,
|
||||
}),
|
||||
});
|
||||
client.getRooms = jest.fn().mockReturnValue([room]);
|
||||
client.getRoom = jest.fn().mockReturnValue(room);
|
||||
|
||||
localStorageSetPromise = new Promise((resolve) => {
|
||||
localStorageSetItemSpy = jest
|
||||
.spyOn(localStorage.__proto__, "setItem")
|
||||
.mockImplementation(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("migrates URL previews setting for e2ee rooms", async () => {
|
||||
SettingsStore.runMigrations(false);
|
||||
client.emit(ClientEvent.Sync, SyncState.Prepared, null);
|
||||
|
||||
expect(room.getAccountData).toHaveBeenCalled();
|
||||
|
||||
await localStorageSetPromise;
|
||||
|
||||
expect(localStorageSetItemSpy!).toHaveBeenCalledWith(
|
||||
`mx_setting_urlPreviewsEnabled_e2ee_${room.roomId}`,
|
||||
JSON.stringify({ value: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not migrate e2ee URL previews on a fresh login", async () => {
|
||||
SettingsStore.runMigrations(true);
|
||||
client.emit(ClientEvent.Sync, SyncState.Prepared, null);
|
||||
|
||||
expect(room.getAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate if the device is flagged as migrated", async () => {
|
||||
jest.spyOn(localStorage.__proto__, "getItem").mockImplementation((key: unknown): string | undefined => {
|
||||
if (key === "url_previews_e2ee_migration_done") return JSON.stringify({ value: true });
|
||||
return undefined;
|
||||
});
|
||||
SettingsStore.runMigrations(false);
|
||||
client.emit(ClientEvent.Sync, SyncState.Prepared, null);
|
||||
|
||||
expect(room.getAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Migrate media preview configuration", () => {
|
||||
beforeEach(() => {
|
||||
MatrixClientBackedController.matrixClient = client;
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 RequiresSettingsController from "../../../../src/settings/controllers/RequiresSettingsController";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
|
||||
describe("RequiresSettingsController", () => {
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("forces a value if a setting is false", async () => {
|
||||
const forcedValue = true;
|
||||
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
|
||||
await SettingsStore.setValue("useCustomFontSize", null, SettingLevel.DEVICE, false);
|
||||
const controller = new RequiresSettingsController(["useCompactLayout", "useCustomFontSize"], forcedValue);
|
||||
expect(controller.settingDisabled).toEqual(true);
|
||||
expect(controller.getValueOverride()).toEqual(forcedValue);
|
||||
});
|
||||
|
||||
it("does not force a value if all settings are true", async () => {
|
||||
const controller = new RequiresSettingsController(["useCompactLayout", "useCustomFontSize"]);
|
||||
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
|
||||
await SettingsStore.setValue("useCustomFontSize", null, SettingLevel.DEVICE, true);
|
||||
expect(controller.settingDisabled).toEqual(false);
|
||||
expect(controller.getValueOverride()).toEqual(null);
|
||||
});
|
||||
});
|
||||
@ -885,6 +885,33 @@ describe("RoomListViewModel", () => {
|
||||
expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]);
|
||||
});
|
||||
|
||||
it("should expand collapsed sections that have results when a filter is toggled on", () => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
// Collapse the favourite section
|
||||
const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
||||
favHeader.onClick();
|
||||
expect(favHeader.isExpanded).toBe(false);
|
||||
|
||||
// Toggle a filter that returns rooms in the favourite section
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
sections: [
|
||||
{ tag: DefaultTagID.Favourite, rooms: [favRoom1] },
|
||||
{ tag: CHATS_TAG, rooms: [] },
|
||||
{ tag: DefaultTagID.LowPriority, rooms: [] },
|
||||
],
|
||||
filterKeys: [FilterEnum.UnreadFilter],
|
||||
});
|
||||
viewModel.onToggleFilter("unread");
|
||||
|
||||
// The favourite section should be expanded and its rooms visible
|
||||
expect(favHeader.isExpanded).toBe(true);
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite);
|
||||
expect(favSection!.roomIds).toEqual(["!fav1:server"]);
|
||||
});
|
||||
|
||||
it("should apply sticky room within the correct section", async () => {
|
||||
stubClient();
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
|
||||
@ -12,4 +12,13 @@ RUN npm i -g playwright@${PLAYWRIGHT_VERSION}
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
# We use `docker-init` as PID 1, which means that the container shuts down correctly on SIGTERM.
|
||||
#
|
||||
# (The problem is that PID 1 doesn't get default signal handlers, and
|
||||
# playwright server doesn't register a SIGTERM handler, so if that ends up as
|
||||
# PID 1, then it ignores SIGTERM. Likewise bash doesn't set a SIGTERM handler by default.
|
||||
#
|
||||
# The easiest solution is to use docker-init, which is in fact `tini` (https://github.com/krallin/tini).
|
||||
#
|
||||
# See https://github.com/krallin/tini/issues/8#issuecomment-146135930 for a good explanation of all this.)
|
||||
ENTRYPOINT ["/usr/bin/docker-init", "/docker-entrypoint.sh"]
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
#!/bin/bash
|
||||
|
||||
# We use npm here as we used `npm i -g` to install playwright in the Dockerfile
|
||||
npm exec -- playwright run-server --port "$PORT" --host 0.0.0.0
|
||||
exec /usr/bin/playwright run-server --port "$PORT" --host 0.0.0.0
|
||||
|
||||
@ -24,6 +24,8 @@ type PaginationLinks = {
|
||||
first?: string;
|
||||
};
|
||||
|
||||
const ANSI_COLOUR_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
// We see quite a few test flakes which are caused by the app exploding
|
||||
// so we have some magic strings we check the logs for to better track the flake with its cause
|
||||
const SPECIAL_CASES: Record<string, string> = {
|
||||
@ -38,18 +40,35 @@ class FlakyReporter implements Reporter {
|
||||
public onTestEnd(test: TestCase): void {
|
||||
// Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
|
||||
if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return;
|
||||
let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`];
|
||||
|
||||
if (test.outcome() === "flaky") {
|
||||
const failures: string[] = [];
|
||||
|
||||
const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
|
||||
const pageLogs = timedOutRuns.flatMap((result) =>
|
||||
result.attachments.filter((attachment) => attachment.name.startsWith("page-")),
|
||||
);
|
||||
|
||||
// If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
|
||||
const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
|
||||
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)),
|
||||
);
|
||||
if (specialCases.length > 0) {
|
||||
failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]);
|
||||
failures.push(...specialCases.map((specialCase) => SPECIAL_CASES[specialCase]));
|
||||
}
|
||||
|
||||
// Check for fixtures failing to set up
|
||||
const errorMessages = timedOutRuns
|
||||
.map((r) => r.error?.message?.replace(ANSI_COLOUR_REGEX, ""))
|
||||
.filter(Boolean) as string[];
|
||||
for (const error of errorMessages) {
|
||||
if (error.startsWith("Fixture") && error.endsWith("exceeded during setup.")) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length < 1) {
|
||||
failures.push(`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`);
|
||||
}
|
||||
|
||||
for (const title of failures) {
|
||||
|
||||
@ -19,8 +19,15 @@ WS_PORT=3000
|
||||
PW_VERSION=$(pnpm --silent -- playwright --version | awk '{print $2}')
|
||||
IMAGE_NAME="ghcr.io/element-hq/element-web/playwright-server:$PW_VERSION"
|
||||
|
||||
# Pull the image, failing that build the image
|
||||
docker pull "$IMAGE_NAME" 2>/dev/null || build_image "$IMAGE_NAME"
|
||||
# If the image exists in the repository, pull it; otherwise, build it.
|
||||
#
|
||||
# (This explicit test gives the user clearer progress info than just
|
||||
# `docker pull 2>/dev/null || build_image`.)
|
||||
if docker manifest inspect "$IMAGE_NAME" &>/dev/null; then
|
||||
docker pull "$IMAGE_NAME"
|
||||
else
|
||||
build_image "$IMAGE_NAME"
|
||||
fi
|
||||
|
||||
# Start the playwright-server in docker
|
||||
CONTAINER=$(docker run --network=host -v /tmp:/tmp --rm -d -e PORT="$WS_PORT" "$IMAGE_NAME")
|
||||
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
import { I18nApi } from "./I18nApi";
|
||||
|
||||
@ -20,4 +21,21 @@ describe("I18nApi", () => {
|
||||
|
||||
expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
it("can register a translation and use it with tags", () => {
|
||||
const i18n = new I18nApi();
|
||||
i18n.register({
|
||||
["hello.world" as TranslationKey]: {
|
||||
en: "Hello, <Bold>World!</Bold>",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
i18n.translate("hello.world" as TranslationKey, {}, { Bold: (sub) => <strong>{sub}</strong> }),
|
||||
).toStrictEqual(
|
||||
<span>
|
||||
Hello, <strong>World!</strong>
|
||||
</span>,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -5,7 +5,12 @@ 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 { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api";
|
||||
import {
|
||||
type I18nApi as II18nApi,
|
||||
type Variables,
|
||||
type Translations,
|
||||
type Tags,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
|
||||
import { humanizeTime } from "../utils/humanize";
|
||||
import { _t, getLocale, registerTranslations } from "./i18n";
|
||||
@ -41,8 +46,12 @@ export class I18nApi implements II18nApi {
|
||||
* Perform a translation, with optional variables
|
||||
* @param key - The key to translate
|
||||
* @param variables - Optional variables to interpolate into the translation
|
||||
* @param tags - Optional tags to interpolate into the translation
|
||||
*/
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables): string {
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables): string;
|
||||
public translate(this: void, key: TranslationKey, variables: Variables | undefined, tags: Tags): React.ReactNode;
|
||||
public translate(this: void, key: TranslationKey, variables?: Variables, tags?: Tags): React.ReactNode | string {
|
||||
if (tags) return _t(key, variables, tags);
|
||||
return _t(key, variables);
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ export * from "./room/timeline/event-tile/body/EventContentBodyView";
|
||||
export * from "./room/timeline/event-tile/body/RedactedBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MFileBodyView";
|
||||
export * from "./room/timeline/event-tile/body/MVideoBodyView";
|
||||
export * from "./room/timeline/event-tile/body/TextualBodyView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";
|
||||
export * from "./core/pill-input/Pill";
|
||||
export * from "./core/pill-input/PillInput";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.root {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.text,
|
||||
.caption {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.notice {
|
||||
white-space: pre-wrap;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.emote {
|
||||
white-space: pre-wrap;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.annotated {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.annotatedInline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
margin-inline-start: 9px; /* Preserve legacy EventTile spacing for inline annotations like (edited) */
|
||||
font: var(--cpd-font-body-xs-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bodyLink,
|
||||
.bodyAction {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.bodyAction {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.emoteSender {
|
||||
all: unset;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editedMarker:focus-visible,
|
||||
.bodyAction:focus-visible,
|
||||
.emoteSender:focus-visible {
|
||||
outline: 2px solid var(--cpd-color-border-focused);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--cpd-space-0-5x);
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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, type ReactElement, type ReactNode } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
|
||||
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||
import {
|
||||
TextualBodyView,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
TextualBodyViewKind,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewSnapshot,
|
||||
} from "./TextualBodyView";
|
||||
|
||||
type WrapperProps = TextualBodyViewSnapshot &
|
||||
Partial<TextualBodyViewActions> & {
|
||||
body: ReactElement;
|
||||
urlPreviews?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TextualBodyViewWrapperImpl = ({
|
||||
body,
|
||||
urlPreviews,
|
||||
className,
|
||||
onRootClick,
|
||||
onBodyActionClick,
|
||||
onEditedMarkerClick,
|
||||
onEmoteSenderClick,
|
||||
...snapshotProps
|
||||
}: WrapperProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshotProps, {
|
||||
onRootClick: onRootClick ?? fn(),
|
||||
onBodyActionClick: onBodyActionClick ?? fn(),
|
||||
onEditedMarkerClick: onEditedMarkerClick ?? fn(),
|
||||
onEmoteSenderClick: onEmoteSenderClick ?? fn(),
|
||||
});
|
||||
|
||||
return <TextualBodyView vm={vm} body={body} urlPreviews={urlPreviews} className={className} />;
|
||||
};
|
||||
|
||||
const TextualBodyViewWrapper = withViewDocs(TextualBodyViewWrapperImpl, TextualBodyView);
|
||||
|
||||
const DefaultBody = <div>Hello, this is a textual message.</div>;
|
||||
const Preview = (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--cpd-color-bg-subtle-secondary)",
|
||||
}}
|
||||
>
|
||||
URL preview
|
||||
</div>
|
||||
);
|
||||
|
||||
const TEXTUAL_BODY_VIEW_KIND_OPTIONS = [
|
||||
TextualBodyViewKind.TEXT,
|
||||
TextualBodyViewKind.NOTICE,
|
||||
TextualBodyViewKind.EMOTE,
|
||||
TextualBodyViewKind.CAPTION,
|
||||
];
|
||||
|
||||
const TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS = [
|
||||
TextualBodyViewBodyWrapperKind.NONE,
|
||||
TextualBodyViewBodyWrapperKind.LINK,
|
||||
TextualBodyViewBodyWrapperKind.ACTION,
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "MessageBody/TextualBody",
|
||||
component: TextualBodyViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
kind: {
|
||||
options: TEXTUAL_BODY_VIEW_KIND_OPTIONS,
|
||||
control: { type: "select" },
|
||||
},
|
||||
bodyWrapper: {
|
||||
options: TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS,
|
||||
control: { type: "select" },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.NONE,
|
||||
body: DefaultBody,
|
||||
urlPreviews: undefined,
|
||||
showEditedMarker: false,
|
||||
editedMarkerText: "(edited)",
|
||||
editedMarkerTooltip: "Edited yesterday at 11:48",
|
||||
editedMarkerCaption: "View edit history",
|
||||
showPendingModerationMarker: false,
|
||||
pendingModerationText: "(Visible to you while moderation is pending)",
|
||||
emoteSenderName: "Alice",
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
},
|
||||
} satisfies Meta<typeof TextualBodyViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Notice: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.NOTICE,
|
||||
body: <div>This is a notice message.</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const CaptionWithPreview: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.CAPTION,
|
||||
body: <div>Caption for the uploaded image.</div>,
|
||||
urlPreviews: Preview,
|
||||
},
|
||||
};
|
||||
|
||||
export const Edited: Story = {
|
||||
args: {
|
||||
showEditedMarker: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PendingModeration: Story = {
|
||||
args: {
|
||||
showPendingModerationMarker: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const HighlightLink: Story = {
|
||||
args: {
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.LINK,
|
||||
bodyLinkHref: "https://example.org/#/room/!room:example.org/$event",
|
||||
},
|
||||
};
|
||||
|
||||
export const StarterLink: Story = {
|
||||
args: {
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
body: <div>Launch the integration flow.</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const Emote: Story = {
|
||||
args: {
|
||||
kind: TextualBodyViewKind.EMOTE,
|
||||
body: <span>waves enthusiastically</span>,
|
||||
showEditedMarker: true,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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, { createRef, type MouseEventHandler } from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MockViewModel } from "../../../../../core/viewmodel";
|
||||
import {
|
||||
TextualBodyView,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
TextualBodyViewKind,
|
||||
type TextualBodyContentElement,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewModel,
|
||||
type TextualBodyViewSnapshot,
|
||||
} from "./TextualBodyView";
|
||||
import * as publicApi from "./index";
|
||||
import * as stories from "./TextualBody.stories";
|
||||
|
||||
const { Default, Notice, CaptionWithPreview, Emote } = composeStories(stories);
|
||||
|
||||
describe("TextualBodyView", () => {
|
||||
it("renders the default message body", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the notice branch", () => {
|
||||
const { container } = render(<Notice />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders caption messages with url previews", () => {
|
||||
const { container } = render(<CaptionWithPreview />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders emote messages with annotations", () => {
|
||||
const { container } = render(<Emote />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("re-exports the public TextualBodyView API", () => {
|
||||
expect(publicApi.TextualBodyView).toBe(TextualBodyView);
|
||||
});
|
||||
|
||||
it("forwards body refs to the rendered body element", () => {
|
||||
const bodyRef = createRef<TextualBodyContentElement>();
|
||||
const vm = new MockViewModel<TextualBodyViewSnapshot>({
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
}) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<div>Body content</div>} bodyRef={bodyRef} />);
|
||||
|
||||
expect(bodyRef.current).not.toBeNull();
|
||||
expect(bodyRef.current?.textContent).toBe("Body content");
|
||||
});
|
||||
|
||||
it("invokes edited marker, body action, and emote sender handlers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditedMarkerClick = vi.fn();
|
||||
const onBodyActionClick = vi.fn();
|
||||
const onEmoteSenderClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
public onEmoteSenderClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.EMOTE,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
showEditedMarker: true,
|
||||
editedMarkerText: "(edited)",
|
||||
editedMarkerTooltip: "Edited yesterday at 11:48",
|
||||
editedMarkerCaption: "View edit history",
|
||||
emoteSenderName: "Alice",
|
||||
},
|
||||
{
|
||||
onEditedMarkerClick,
|
||||
onBodyActionClick,
|
||||
onEmoteSenderClick,
|
||||
},
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<span>waves</span>} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alice" }));
|
||||
await user.click(screen.getByRole("button", { name: "Open starter link" }));
|
||||
await user.click(screen.getByRole("button", { name: "(edited)" }));
|
||||
|
||||
expect(onEmoteSenderClick).toHaveBeenCalledTimes(1);
|
||||
expect(onBodyActionClick).toHaveBeenCalledTimes(1);
|
||||
expect(onEditedMarkerClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders link-wrapped annotated bodies without an edited tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditedMarkerClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.LINK,
|
||||
bodyLinkHref: "https://example.org/#/room/!room:example.org/$event",
|
||||
showEditedMarker: true,
|
||||
editedMarkerText: "(edited)",
|
||||
showPendingModerationMarker: true,
|
||||
pendingModerationText: "(Visible to you while moderation is pending)",
|
||||
},
|
||||
{ onEditedMarkerClick },
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<div>Body content</div>} />);
|
||||
|
||||
expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.org/#/room/!room:example.org/$event");
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("(Visible to you while moderation is pending)")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "(edited)" }));
|
||||
|
||||
expect(onEditedMarkerClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders action wrappers as native buttons and activates them for Enter and Space key presses", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBodyActionClick = vi.fn();
|
||||
|
||||
class TestTextualBodyViewModel
|
||||
extends MockViewModel<TextualBodyViewSnapshot>
|
||||
implements TextualBodyViewActions
|
||||
{
|
||||
public onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
|
||||
public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) {
|
||||
super(snapshot);
|
||||
Object.assign(this, actions);
|
||||
}
|
||||
}
|
||||
|
||||
const vm = new TestTextualBodyViewModel(
|
||||
{
|
||||
kind: TextualBodyViewKind.TEXT,
|
||||
bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION,
|
||||
bodyActionAriaLabel: "Open starter link",
|
||||
},
|
||||
{ onBodyActionClick },
|
||||
) as TextualBodyViewModel;
|
||||
|
||||
render(<TextualBodyView vm={vm} body={<span>Launch the integration flow.</span>} />);
|
||||
|
||||
const action = screen.getByRole("button", { name: "Open starter link" });
|
||||
expect(action).toHaveAttribute("type", "button");
|
||||
|
||||
action.focus();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.keyboard("{Enter}");
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(onBodyActionClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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, {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type JSX,
|
||||
type MouseEventHandler,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import styles from "./TextualBody.module.css";
|
||||
|
||||
export const enum TextualBodyViewKind {
|
||||
TEXT = "TEXT",
|
||||
NOTICE = "NOTICE",
|
||||
EMOTE = "EMOTE",
|
||||
CAPTION = "CAPTION",
|
||||
}
|
||||
|
||||
export const enum TextualBodyViewBodyWrapperKind {
|
||||
NONE = "NONE",
|
||||
LINK = "LINK",
|
||||
ACTION = "ACTION",
|
||||
}
|
||||
|
||||
export interface TextualBodyViewSnapshot {
|
||||
/**
|
||||
* Optional id passed to the root message-body element.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Controls the layout and styling branch for the body.
|
||||
*/
|
||||
kind: TextualBodyViewKind;
|
||||
/**
|
||||
* Optional outer wrapper applied around the rendered body content.
|
||||
*/
|
||||
bodyWrapper?: TextualBodyViewBodyWrapperKind;
|
||||
/**
|
||||
* Href used when `bodyWrapper` is `LINK`.
|
||||
*/
|
||||
bodyLinkHref?: string;
|
||||
/**
|
||||
* Accessible label used when `bodyWrapper` is `ACTION`.
|
||||
*/
|
||||
bodyActionAriaLabel?: string;
|
||||
/**
|
||||
* Whether to render the edited marker.
|
||||
*/
|
||||
showEditedMarker?: boolean;
|
||||
/**
|
||||
* Visible label for the edited marker.
|
||||
*/
|
||||
editedMarkerText?: string;
|
||||
/**
|
||||
* Tooltip description for the edited marker.
|
||||
*/
|
||||
editedMarkerTooltip?: string;
|
||||
/**
|
||||
* Optional tooltip caption for the edited marker.
|
||||
*/
|
||||
editedMarkerCaption?: string;
|
||||
/**
|
||||
* Whether to render the pending-moderation marker.
|
||||
*/
|
||||
showPendingModerationMarker?: boolean;
|
||||
/**
|
||||
* Visible label for the pending-moderation marker.
|
||||
*/
|
||||
pendingModerationText?: string;
|
||||
/**
|
||||
* Sender label rendered for emote events.
|
||||
*/
|
||||
emoteSenderName?: string;
|
||||
}
|
||||
|
||||
export interface TextualBodyViewActions {
|
||||
/**
|
||||
* Capture-phase click handler attached to the root message-body container.
|
||||
*/
|
||||
onRootClick?: MouseEventHandler<HTMLDivElement>;
|
||||
/**
|
||||
* Activation handler used when `bodyWrapper` is `ACTION`.
|
||||
*/
|
||||
onBodyActionClick?: MouseEventHandler<HTMLElement>;
|
||||
/**
|
||||
* Click handler for the edited marker.
|
||||
*/
|
||||
onEditedMarkerClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Click handler for the emote sender.
|
||||
*/
|
||||
onEmoteSenderClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export type TextualBodyViewModel = ViewModel<TextualBodyViewSnapshot, TextualBodyViewActions>;
|
||||
|
||||
export type TextualBodyContentElement = HTMLDivElement | HTMLSpanElement;
|
||||
export type TextualBodyContentRef = Ref<TextualBodyContentElement>;
|
||||
|
||||
interface TextualBodyViewProps {
|
||||
/**
|
||||
* The view model providing the layout state and event handlers.
|
||||
*/
|
||||
vm: TextualBodyViewModel;
|
||||
/**
|
||||
* The message body element, typically `EventContentBodyView`.
|
||||
*/
|
||||
body: ReactElement;
|
||||
/**
|
||||
* Optional ref to attach to the message body element.
|
||||
*/
|
||||
bodyRef?: TextualBodyContentRef;
|
||||
/**
|
||||
* Optional URL preview subtree rendered after the body.
|
||||
*/
|
||||
urlPreviews?: ReactNode;
|
||||
/**
|
||||
* Optional host-level class names.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-clones the supplied body element so consumers can observe the rendered
|
||||
* body node via `bodyRef` without constraining the `body` prop shape.
|
||||
*/
|
||||
function attachBodyRef(body: ReactElement, bodyRef?: TextualBodyContentRef): ReactElement {
|
||||
if (!bodyRef || !isValidElement(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return cloneElement(body as ReactElement<{ ref?: TextualBodyContentRef }>, { ref: bodyRef });
|
||||
}
|
||||
|
||||
export function TextualBodyView({
|
||||
vm,
|
||||
body,
|
||||
bodyRef,
|
||||
urlPreviews,
|
||||
className,
|
||||
}: Readonly<TextualBodyViewProps>): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
kind,
|
||||
bodyWrapper = TextualBodyViewBodyWrapperKind.NONE,
|
||||
bodyLinkHref,
|
||||
bodyActionAriaLabel,
|
||||
showEditedMarker,
|
||||
editedMarkerText,
|
||||
editedMarkerTooltip,
|
||||
editedMarkerCaption,
|
||||
showPendingModerationMarker,
|
||||
pendingModerationText,
|
||||
emoteSenderName,
|
||||
} = useViewModel(vm);
|
||||
|
||||
const rootClasses = classNames(className, styles.root, {
|
||||
[styles.text]: kind === TextualBodyViewKind.TEXT,
|
||||
[styles.notice]: kind === TextualBodyViewKind.NOTICE,
|
||||
[styles.emote]: kind === TextualBodyViewKind.EMOTE,
|
||||
[styles.caption]: kind === TextualBodyViewKind.CAPTION,
|
||||
});
|
||||
|
||||
let renderedBody: ReactNode = attachBodyRef(body, bodyRef);
|
||||
const onEditedMarkerClick: MouseEventHandler<HTMLButtonElement> | undefined = vm.onEditedMarkerClick
|
||||
? (event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
vm.onEditedMarkerClick?.(event);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const markers: ReactNode[] = [];
|
||||
if (showEditedMarker) {
|
||||
const editedMarkerButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.annotation, styles.editedMarker)}
|
||||
onClick={onEditedMarkerClick}
|
||||
>
|
||||
<span>{editedMarkerText}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
markers.push(
|
||||
editedMarkerTooltip ? (
|
||||
<Tooltip
|
||||
key="edited-marker"
|
||||
description={editedMarkerTooltip}
|
||||
caption={editedMarkerCaption}
|
||||
isTriggerInteractive={true}
|
||||
>
|
||||
{editedMarkerButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
React.cloneElement(editedMarkerButton, { key: "edited-marker" })
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (showPendingModerationMarker) {
|
||||
markers.push(
|
||||
<span key="pending-moderation-marker" className={styles.annotation}>
|
||||
{pendingModerationText}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyWrapper === TextualBodyViewBodyWrapperKind.LINK && bodyLinkHref) {
|
||||
renderedBody = (
|
||||
<a href={bodyLinkHref} className={styles.bodyLink}>
|
||||
{renderedBody}
|
||||
</a>
|
||||
);
|
||||
} else if (bodyWrapper === TextualBodyViewBodyWrapperKind.ACTION) {
|
||||
renderedBody = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bodyActionAriaLabel}
|
||||
className={styles.bodyAction}
|
||||
onClick={vm.onBodyActionClick}
|
||||
>
|
||||
{renderedBody}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (markers.length > 0) {
|
||||
const annotatedClasses = classNames(styles.annotated, {
|
||||
[styles.annotatedInline]: kind === TextualBodyViewKind.EMOTE,
|
||||
});
|
||||
|
||||
renderedBody =
|
||||
kind === TextualBodyViewKind.EMOTE ? (
|
||||
<span dir="auto" className={annotatedClasses}>
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</span>
|
||||
) : (
|
||||
<div dir="auto" className={annotatedClasses}>
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === TextualBodyViewKind.EMOTE) {
|
||||
return (
|
||||
<div id={id} className={rootClasses} onClickCapture={vm.onRootClick} dir="auto">
|
||||
*
|
||||
<button type="button" className={styles.emoteSender} onClick={vm.onEmoteSenderClick}>
|
||||
{emoteSenderName}
|
||||
</button>
|
||||
|
||||
{renderedBody}
|
||||
{urlPreviews}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={id} className={rootClasses} onClickCapture={vm.onRootClick}>
|
||||
{renderedBody}
|
||||
{urlPreviews}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TextualBodyView > renders caption messages with url previews 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root caption"
|
||||
>
|
||||
<div>
|
||||
Caption for the uploaded image.
|
||||
</div>
|
||||
<div
|
||||
style="margin-top: 8px; padding: 8px; border-radius: 8px; background-color: var(--cpd-color-bg-subtle-secondary);"
|
||||
>
|
||||
URL preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders emote messages with annotations 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root emote"
|
||||
dir="auto"
|
||||
>
|
||||
*
|
||||
<button
|
||||
class="emoteSender"
|
||||
type="button"
|
||||
>
|
||||
Alice
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="annotated annotatedInline"
|
||||
dir="auto"
|
||||
>
|
||||
<span>
|
||||
waves enthusiastically
|
||||
</span>
|
||||
<button
|
||||
class="annotation editedMarker"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
(edited)
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders the default message body 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root text"
|
||||
>
|
||||
<div>
|
||||
Hello, this is a textual message.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TextualBodyView > renders the notice branch 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="root notice"
|
||||
>
|
||||
<div>
|
||||
This is a notice message.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 {
|
||||
TextualBodyView,
|
||||
TextualBodyViewKind,
|
||||
TextualBodyViewBodyWrapperKind,
|
||||
type TextualBodyViewSnapshot,
|
||||
type TextualBodyViewActions,
|
||||
type TextualBodyViewModel,
|
||||
type TextualBodyContentElement,
|
||||
type TextualBodyContentRef,
|
||||
} from "./TextualBodyView";
|
||||
24
pnpm-lock.yaml
generated
@ -7,8 +7,8 @@ settings:
|
||||
catalogs:
|
||||
default:
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 1.12.0
|
||||
version: 1.12.0
|
||||
specifier: 1.13.0
|
||||
version: 1.13.0
|
||||
'@fontsource/inter':
|
||||
specifier: 5.2.8
|
||||
version: 5.2.8
|
||||
@ -333,7 +333,7 @@ importers:
|
||||
version: 7.28.6
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 'catalog:'
|
||||
version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@element-hq/web-shared-components':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-components
|
||||
@ -462,7 +462,7 @@ importers:
|
||||
version: 1.0.3
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3
|
||||
matrix-widget-api:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
@ -956,7 +956,7 @@ importers:
|
||||
devDependencies:
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: '*'
|
||||
version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@types/lodash-es':
|
||||
specifier: ^4.17.12
|
||||
version: 4.17.12
|
||||
@ -971,7 +971,7 @@ importers:
|
||||
dependencies:
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 'catalog:'
|
||||
version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@matrix-org/spec':
|
||||
specifier: ^1.7.0
|
||||
version: 1.16.0
|
||||
@ -2429,8 +2429,8 @@ packages:
|
||||
'@element-hq/element-call-embedded@0.18.0':
|
||||
resolution: {integrity: sha512-Fg2VlORZWkQ9t9OJTcWFXCwVzlHVLtkaiCF0qFTCOZSYYHlA3kXDRM8TagjLkIoOVR6y+9xZldbwejgKYUS9xw==}
|
||||
|
||||
'@element-hq/element-web-module-api@1.12.0':
|
||||
resolution: {integrity: sha512-fLhHFiL1UbRjolpgera3osHHxhSzfnDGTRhaDEv1UsrHRHwMu3hb/IcyXNqGhLXkJiuX8XoOH0aetaAUqQ0YQA==}
|
||||
'@element-hq/element-web-module-api@1.13.0':
|
||||
resolution: {integrity: sha512-3QXejLpXHK52e/BM61zeFQt1pnmKEfhFsooKI3OOXa5M9io683q1eA986TquZTDHoorm0Q+4TyxjYD3j2Nkp8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@matrix-org/react-sdk-module-api': '*'
|
||||
@ -9873,8 +9873,8 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1:
|
||||
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1}
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3:
|
||||
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3}
|
||||
version: 41.3.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
@ -14934,7 +14934,7 @@ snapshots:
|
||||
|
||||
'@element-hq/element-call-embedded@0.18.0': {}
|
||||
|
||||
'@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
|
||||
'@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||
@ -23473,7 +23473,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.0.0
|
||||
|
||||
@ -16,7 +16,7 @@ catalog:
|
||||
"@playwright/test": 1.59.1
|
||||
"playwright-core": 1.59.1
|
||||
# Module API
|
||||
"@element-hq/element-web-module-api": 1.12.0
|
||||
"@element-hq/element-web-module-api": 1.13.0
|
||||
# Compound
|
||||
"@vector-im/compound-design-tokens": 8.0.0
|
||||
"@vector-im/compound-web": 8.4.0
|
||||
|
||||