Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/merge-modules

# Conflicts:
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
This commit is contained in:
Michael Telatynski
2026-06-09 12:39:57 +01:00
46 changed files with 439 additions and 422 deletions
+1 -1
View File
@@ -88,7 +88,7 @@
"app-builder-lib": "26.12.1",
"chokidar": "^5.0.0",
"detect-libc": "^2.0.0",
"electron": "42.3.0",
"electron": "42.3.3",
"electron-builder": "26.12.1",
"electron-builder-squirrel-windows": "26.12.1",
"electron-devtools-installer": "^4.0.0",
+1 -1
View File
@@ -14,8 +14,8 @@ WORKDIR /src
# Install dependencies
COPY --parents package.json pnpm-lock.yaml pnpm-workspace.yaml patches scripts **/package.json /src/
RUN corepack enable
RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-link-repos.sh
RUN pnpm install --frozen-lockfile
RUN --mount=type=bind,source=.git,target=/src/.git /src/scripts/docker-link-repos.sh
# Build
COPY --link --exclude=.git --exclude=apps/web/docker . /src
+1 -1
View File
@@ -91,7 +91,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.376.6",
"posthog-js": "1.381.0",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "catalog:",
@@ -371,5 +371,33 @@ test.describe("Room list custom sections", () => {
// Room is back in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
test("should remove a room from a custom section via the 'Remove from section' menu entry", async ({
page,
app,
}) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
const roomList = getRoomList(page);
// Move the room to the Work section
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
await assertRoomInSection(page, "Work", "my room");
// Open the More Options menu and click "Remove from section"
roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Remove from section" }).click();
// Room is back in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
const DOCKER_IMAGE =
"ghcr.io/element-hq/synapse:develop@sha256:3e079baccf55f491bd40c8b218789f0e8b31f466f6e23b14baab4a52ca845284";
"ghcr.io/element-hq/synapse:develop@sha256:e5e02e25d3f3c38ae399acf74c359e971c2fd1381b88e0031a1faa9c785a0d91";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,
@@ -21,3 +21,7 @@
width: 100%;
}
}
.mx_CreateSectionDialog_edition > .mx_Dialog_header {
margin-bottom: var(--cpd-space-6x);
}
@@ -35,6 +35,9 @@ Please see LICENSE files in the repository root for full details.
.mx_DevTools_content {
overflow-y: auto;
/* Give room for focus outlines */
padding-inline-start: var(--cpd-space-0-5x);
padding-block-end: var(--cpd-space-0-5x);
}
.mx_DevTools_RoomStateExplorer_query {
@@ -202,8 +202,18 @@ class LoggedInView extends React.Component<IProps, IState> {
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
this.refreshBackgroundImage();
}
this.resizerViewModel = new ResizerViewModel();
private getResizerViewModel(): ResizerViewModel {
if (!this.resizerViewModel) {
this.resizerViewModel = new ResizerViewModel();
}
return this.resizerViewModel;
}
private disposeResizerViewModel(): void {
this.resizerViewModel?.dispose();
this.resizerViewModel = undefined;
}
/**
@@ -739,6 +749,8 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
default: {
if (moduleRenderer) {
// Since the view will be removed, remove the vm as well
this.disposeResizerViewModel();
pageElement = moduleRenderer();
} else {
console.warn(`Couldn't render page type "${this.props.page_type}"`);
@@ -797,14 +809,15 @@ class LoggedInView extends React.Component<IProps, IState> {
const roomView = <div className="mx_RoomView_wrapper">{pageElement}</div>;
let content: React.ReactNode;
if (useNewRoomList && this.resizerViewModel && !moduleRenderer) {
const resizerViewModel = !moduleRenderer ? this.getResizerViewModel() : undefined;
if (useNewRoomList && resizerViewModel && !moduleRenderer) {
// New room list owned by element-web: resizable layout with a draggable separator.
// The SpacePanel lives inside GroupView (leftPanel omits it when the new room list is enabled).
content = (
<GroupView vm={this.resizerViewModel}>
<GroupView vm={resizerViewModel}>
<SpacePanel />
<LeftResizablePanelView
vm={this.resizerViewModel}
vm={resizerViewModel}
className="mx_LeftPanel_panel"
minSize="200px"
maxSize="370px"
@@ -812,7 +825,7 @@ class LoggedInView extends React.Component<IProps, IState> {
>
{leftPanel}
</LeftResizablePanelView>
<SeparatorView className="mx_Separator" vm={this.resizerViewModel} />
<SeparatorView className="mx_Separator" vm={resizerViewModel} />
<Panel className="mx_LeftPanel_panel">{roomView}</Panel>
</GroupView>
);
@@ -8,6 +8,7 @@
import React, { useState, type JSX } from "react";
import { Flex } from "@element-hq/web-shared-components";
import { Form, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
@@ -37,15 +38,19 @@ export function CreateSectionDialog({ onFinished, sectionToEdit }: CreateSection
return (
<BaseDialog
className="mx_CreateSectionDialog"
className={classNames("mx_CreateSectionDialog", {
mx_CreateSectionDialog_edition: isEdition,
})}
onFinished={() => onFinished(false, value)}
title={isEdition ? _t("create_section_dialog|title_edition") : _t("create_section_dialog|title")}
hasCancel={true}
>
<Flex gap="var(--cpd-space-6x)" direction="column" className="mx_CreateSectionDialog_content">
<Text as="span" weight="medium">
{_t("create_section_dialog|description")}
</Text>
{!isEdition && (
<Text as="span" weight="medium">
{_t("create_section_dialog|description")}
</Text>
)}
<Form.Root
className="mx_CreateSectionDialog_form"
onSubmit={(e) => {
+16 -3
View File
@@ -246,7 +246,9 @@
"check_code_heading": "Sisesta teises seadmes kuvatav number",
"check_code_input_label": "2-kohaline kood",
"check_code_mismatch": "Numbrid ei klapi",
"choose_desktop_computer": "Vali „%(desktopComputer)s“",
"completing_setup": "Lõpetame uue seadme seadistamise",
"desktop_computer": "Lauaarvuti",
"error_etag_missing": "Tekkis ootamatu viga. Selle põhjuseks võivad olla brauseri lisamoodul, proksiserveri seadistused või koduserveri vigased seadistused.",
"error_expired": "Sisselogimine aegus. Palun proovi uuesti.",
"error_expired_title": "Sisselogimine ei jõudnud õigeaegselt lõpule",
@@ -267,15 +269,22 @@
"error_user_declined": "Sa keeldusid teises seadmes sisselogimispäringust.",
"error_user_declined_title": "Sa keeldusid sisselogimast",
"follow_remaining_instructions": "Teise seadme verifitseerimiseks järgi ülejäänud juhiseid",
"open_element_mobile_device": "Ava oma nutiseadmes %(brand)s",
"open_element_other_device": "Ava %(brand)s oma teises seadmes",
"point_the_camera": "Suuna kaamera siin näidatud QR-koodi peale",
"scan_code_instruction": "Skaneeri QR-koodi teise seadmega",
"ready_to_scan": "Skaneerimiseks valmis",
"scan_code_instruction": "Skaneeri seda QR-koodi",
"scan_code_instruction_reciprocate": "Skaneeri seda QR-koodi teise seadmega",
"scan_qr_code": "Logi sisse QR-koodi alusel",
"security_code_prompt": "Kui seda küsitakse, sisesta teises seadmes allolev kood.",
"security_code_prompt": "Sinu teenusepakkuja võib sisselogimise verifitseerimiseks küsida seda koodi.",
"security_code_title": "Sinu verifitseerimiskood",
"select_qr_code": "Val i „%(scanQRCode)s“",
"select_ready_to_scan": "Vali „%(readyToScan)s“ ja skaneeri siinnäidatud QR-koodi",
"start_over": "Alusta otsast peale",
"tap_avatar_link_new_device": "Klõpsa oma tunnuspilti ja vali „%(linkNewDevice)s“",
"unsupported_explainer": "Sinu teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil.",
"unsupported_heading": "QR-koodi kasutamine pole toetatud",
"waiting_for_device": "Ootame, et teine seade logiks võrku"
"waiting_for_device": "Ootame sinu teise seadme järgi"
},
"register_action": "Loo konto",
"registration": {
@@ -334,8 +343,10 @@
"sign_in_description": "Jätkamaks kasuta oma kontot.",
"sign_in_instead": "Pigem logi sisse",
"sign_in_instead_prompt": "Sul juba on kasutajakonto olemas? <a>Siis logi siin sisse</a>",
"sign_in_manually": "Logi sisse käsitsi",
"sign_in_or_register": "Logi sisse või loo uus konto",
"sign_in_or_register_description": "Jätkamaks kasuta oma kontot või loo uus konto.",
"sign_in_with_qr": "Logi sisse QR-koodi alusel",
"sign_in_with_sso": "Logi sisse ühekordse sisselogimise abil",
"signing_in": "Login sisse…",
"soft_logout": {
@@ -1517,6 +1528,7 @@
"bridge_state_manager": "Seda võrgusilda haldab <user />.",
"bridge_state_workspace": "Tööruum: <networkLink/>",
"click_for_info": "Lisateabe jaoks klõpsi",
"config_only": "Saab sisselülitada ainult config.json failist.",
"currently_experimental": "Parasjagu katsejärgus.",
"custom_themes": "Kohandatud kujunduste lisamise võimalus",
"dynamic_room_predecessors": "Jututoa dünaamilised eellased",
@@ -1561,6 +1573,7 @@
"leave_beta_reload": "Beeta-funktsionaalsuste kasutamise lõpetamisel laadime uuesti rakenduse %(brand)s.",
"location_share_live": "Asukoha jagamine reaalajas",
"location_share_live_description": "Tegemist on ajutise ja esialgse lahendusega: asukohad on jututoa ajaloos näha.",
"login_with_qr": "Logi sisse QR-koodiga",
"mjolnir": "Uued võimalused osalejate eiramiseks",
"msc3531_hide_messages_pending_moderation": "Luba modereerimist ootavate sõnumite peitmist.",
"new_room_list": "Võta kasutusele uus jututubade loend",
+2 -2
View File
@@ -270,11 +270,11 @@
"point_the_camera": "Scannez le QR code affiché ici",
"scan_code_instruction": "Scannez le code QR",
"scan_qr_code": "Se connecter avec un QR code",
"security_code_prompt": "Si vous y êtes invité, saisissez le code ci-dessous sur votre autre appareil.",
"security_code_prompt": "Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion.",
"select_qr_code": "Sélectionnez « %(scanQRCode)s »",
"unsupported_explainer": "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à laide dun code QR.",
"unsupported_heading": "Le code QR n'est pas pris en charge",
"waiting_for_device": "En attente de connexion de lappareil"
"waiting_for_device": "En attente de votre autre appareil"
},
"register_action": "Créer un compte",
"registration": {
+16 -3
View File
@@ -246,7 +246,9 @@
"check_code_heading": "Wprowadź numer wyświetlany na drugim urządzeniu",
"check_code_input_label": "2-cyfrowy kod",
"check_code_mismatch": "Liczby się nie zgadzają",
"choose_desktop_computer": "Wybierz \"%(desktopComputer)s\"",
"completing_setup": "Kończenie konfiguracji nowego urządzenia",
"desktop_computer": "Komputer stacjonarny",
"error_etag_missing": "Wystąpił nieoczekiwany błąd. Może to być spowodowane rozszerzeniem przeglądarki, serwerem proxy lub błędną konfiguracją serwera.",
"error_expired": "Logowanie wygasło. Spróbuj ponownie.",
"error_expired_title": "Logowanie nie zostało zakończone na czas",
@@ -267,15 +269,22 @@
"error_user_declined": "Ty lub Twój dostawca konta odrzucił żądanie logowania.",
"error_user_declined_title": "Logowanie odrzucone",
"follow_remaining_instructions": "Postępuj zgodnie z pozostałymi instrukcjami, aby zweryfikować Twoje inne urządzenie",
"open_element_mobile_device": "Otwórz %(brand)s na swoim urządzeniu mobilnym",
"open_element_other_device": "Otwórz %(brand)s na innym urządzeniu",
"point_the_camera": "Skieruj kamerę na pokazany tutaj kod QR",
"scan_code_instruction": "Skanuj kod QR za pomocą innego urządzenia",
"ready_to_scan": "Gotowy do skanowania",
"scan_code_instruction": "Skanuj kod QR",
"scan_code_instruction_reciprocate": "Zeskanuj kod QR innym urządzeniem",
"scan_qr_code": "Zaloguj się kodem QR",
"security_code_prompt": "Jeśli zostaniesz poproszony, wprowadź poniższy kod na drugim urządzeniu.",
"security_code_prompt": "Twój dostawca konta może poprosić o podany kod w celu weryfikacji logowania.",
"security_code_title": "Twój kod weryfikacyjny",
"select_qr_code": "Wybierz '%(scanQRCode)s'",
"select_ready_to_scan": "Wybierz \"%(readyToScan)s\" i zeskanuj kod QR widoczny tutaj",
"start_over": "Zacznij od nowa",
"tap_avatar_link_new_device": "Kliknij swój awatar i wybierz \"%(linkNewDevice)s\"",
"unsupported_explainer": "Twój dostawca konta nie obsługuje logowania nowego urządzenia za pomocą kodu QR.",
"unsupported_heading": "Kod QR nie jest wspierany",
"waiting_for_device": "Oczekiwanie na logowanie urządzenia"
"waiting_for_device": "Oczekiwanie na drugie urządzenie"
},
"register_action": "Utwórz konto",
"registration": {
@@ -334,8 +343,10 @@
"sign_in_description": "Użyj swojego konta, aby kontynuować.",
"sign_in_instead": "Zamiast tego zaloguj się",
"sign_in_instead_prompt": "Masz już konto? <a>Zaloguj się tutaj </a>",
"sign_in_manually": "Zaloguj się ręcznie",
"sign_in_or_register": "Zaloguj się lub utwórz konto",
"sign_in_or_register_description": "Użyj konta lub utwórz nowe, aby kontynuować.",
"sign_in_with_qr": "Zaloguj się kodem QR",
"sign_in_with_sso": "Zaloguj się za pomocą pojedynczego logowania",
"signing_in": "Logowanie…",
"soft_logout": {
@@ -1528,6 +1539,7 @@
"bridge_state_manager": "Ten mostek jest zarządzany przez <user />.",
"bridge_state_workspace": "Obszar roboczy: <networkLink/>",
"click_for_info": "Kliknij po więcej informacji",
"config_only": "Może zostać włączony tylko poprzez config.json.",
"currently_experimental": "Aktualnie eksperymentalne.",
"custom_themes": "Obsługa dodawania niestandardowych motywów",
"dynamic_room_predecessors": "Dynamiczne poprzedniki pokoju",
@@ -1572,6 +1584,7 @@
"leave_beta_reload": "Opuszczenie bety, wczyta ponownie %(brand)s.",
"location_share_live": "Udostępnianie lokalizacji na żywo",
"location_share_live_description": "Implementacja tymczasowa. Lokalizacje są zapisywane w historii pokoju.",
"login_with_qr": "Zaloguj się za pomocą kodu QR",
"mjolnir": "Nowe sposoby na ignorowanie osób",
"msc3531_hide_messages_pending_moderation": "Daj moderatorom ukrycie wiadomości które są sprawdzane.",
"new_room_list": "Włącz nową listę pokojów",
+1 -1
View File
@@ -1140,7 +1140,7 @@ export const SETTINGS: Settings = {
"urlPreviewsEnabled": {
// Enabled by default and client configurable as this setting only allows unencrypted
// messages to be previewed.
supportedLevels: [SettingLevel.DEVICE, SettingLevel.ACCOUNT, SettingLevel.CONFIG],
supportedLevels: [SettingLevel.ROOM_DEVICE, SettingLevel.DEVICE, SettingLevel.ACCOUNT, SettingLevel.CONFIG],
supportedLevelsAreOrdered: true,
displayName: _td("settings|inline_url_previews_default"),
default: true,
@@ -37,6 +37,8 @@ export const PREVIEW_WIDTH_PX = 478;
export const PREVIEW_HEIGHT_PX = 200;
export const MIN_PREVIEW_PX = 96;
export const MIN_IMAGE_SIZE_BYTES = 8192;
// From https://github.com/matrix-org/matrix-spec-proposals/pull/4095
export const BUNDLED_LINK_PREVIEWS = "com.beeper.linkpreviews";
export enum PreviewVisibility {
/**
@@ -387,6 +389,18 @@ export class UrlPreviewGroupViewModel
* for the previously-calculated links.
*/
private async computeSnapshot(): Promise<void> {
// This uses MSC4095. If the sender has sent us an empty URL previews bundle
// then they do not want to have URL previews be visible.
const bundledLinkPreviews = this.props.mxEvent.getContent()[BUNDLED_LINK_PREVIEWS];
if (Array.isArray(bundledLinkPreviews) && bundledLinkPreviews.length === 0) {
return this.snapshot.merge({
previews: [],
totalPreviewCount: 0,
previewsLimited: false,
overPreviewLimit: false,
});
} // otherwise, we do not support bundled previews yet so will fallback to old behaviour.
const previews =
this.visibility <= PreviewVisibility.UserHidden
? []
@@ -415,6 +415,14 @@ export class RoomListItemViewModel
tagRoom(this.props.room, tag);
};
public onRemoveFromSection = (): void => {
const roomTags = this.props.room.tags;
const sectionTag = RoomListStoreV3.instance.orderedSectionTags.find((tag) => Boolean(roomTags[tag]));
if (sectionTag) {
tagRoom(this.props.room, sectionTag);
}
};
private onOrderedCustomSectionsChange = (): void => {
// Rebuild sections list to reflect new order
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
@@ -9,7 +9,10 @@ import { expect } from "@jest/globals";
import type { MockedObject } from "jest-mock";
import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { UrlPreviewGroupViewModel } from "../../../src/viewmodels/message-body/UrlPreviewGroupViewModel";
import {
BUNDLED_LINK_PREVIEWS,
UrlPreviewGroupViewModel,
} from "../../../src/viewmodels/message-body/UrlPreviewGroupViewModel";
import type { UrlPreview } from "@element-hq/web-shared-components";
import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils";
@@ -22,7 +25,9 @@ const BASIC_PREVIEW_OGDATA = {
"og:site_name": "Example.org",
};
function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): {
function getViewModel(
{ mediaVisible, visible, showPreview } = { mediaVisible: true, visible: true, showPreview: true },
): {
vm: UrlPreviewGroupViewModel;
client: MockedObject<MatrixClient>;
onImageClicked: jest.Mock<void, [UrlPreview]>;
@@ -41,7 +46,9 @@ function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible:
event: true,
user: "@foo:bar",
type: "m.room.message",
content: {},
content: {
...(showPreview ? undefined : { [BUNDLED_LINK_PREVIEWS]: [] }),
},
id: "$id",
}),
});
@@ -94,7 +101,7 @@ describe("UrlPreviewGroupViewModel", () => {
]);
});
it("should hide preview when invisible", async () => {
const { vm, client } = getViewModel({ visible: false, mediaVisible: true });
const { vm, client } = getViewModel({ visible: false, mediaVisible: true, showPreview: true });
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
@@ -152,7 +159,7 @@ describe("UrlPreviewGroupViewModel", () => {
expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy();
});
it("should ignore media when mediaVisible is false", async () => {
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
const { vm, client } = getViewModel({ mediaVisible: false, visible: true, showPreview: true });
client.getUrlPreview.mockResolvedValueOnce({
"og:title": "This is an example!",
"og:type": "document",
@@ -225,6 +232,21 @@ describe("UrlPreviewGroupViewModel", () => {
await vm.onShowClick();
expect(vm.getSnapshot()).toMatchSnapshot();
});
it("should hide a preview if the message requests it", async () => {
const { vm, client } = getViewModel({ showPreview: false, mediaVisible: true, visible: true });
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
const msg = document.createElement("div");
msg.innerHTML = '<a href="https://example.org">Test</a>';
await vm.updateEventElement(msg);
expect(vm.getSnapshot()).toMatchInlineSnapshot(`
{
"overPreviewLimit": false,
"previews": [],
"previewsLimited": false,
"totalPreviewCount": 0,
}
`);
});
describe("calculates author", () => {
it("should use the profile:username if provided", async () => {
+9 -1
View File
@@ -55,7 +55,15 @@ export default {
// Brought in via hak scripts
"matrix-seshat",
],
ignoreBinaries: ["scripts/in-docker.sh"],
ignoreBinaries: [
"scripts/in-docker.sh",
// Used to build seshat (optional)
"rustc",
// Used by the fetch-package script (optional)
"gpg",
// Used for the macOS universal builds
"lipo",
],
},
"modules/*": {
entry: "src/index.ts{x,}",
+1 -1
View File
@@ -34,7 +34,7 @@
"cronstrue": "^3.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"husky": "^9.0.0",
"knip": "6.15.0",
"knip": "6.16.0",
"lint-staged": "^17.0.0",
"lodash": "^4.17.21",
"mermaid": "^11.13.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+1 -1
View File
@@ -110,7 +110,7 @@
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vector-im/compound-web": "catalog:",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/browser-playwright": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "8",
"eslint-config-google": "^0.14.0",
@@ -150,7 +150,8 @@
"low_priority": "Low priority",
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"move_to_section": "Move to"
"move_to_section": "Move to",
"remove_from_section": "Remove from section"
},
"notification_options": "Notification options",
"open_space_menu": "Open space menu",
@@ -32,6 +32,12 @@ const meta = {
</div>
),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/qurBlLqjf3mRNpyZ1ffamm/ER-213---Sections?node-id=1233-22137&t=ftTEpAma7PgRaaqB-4",
},
},
} satisfies Meta<typeof RoomListToast>;
export default meta;
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentType, type JSX, type MouseEventHandler } from "react";
import React, { type JSX, type MouseEventHandler } from "react";
import { Toast } from "@vector-im/compound-web";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RoomListToast.module.css";
import { useI18n } from "../../../core/i18n/i18nContext";
@@ -32,19 +31,19 @@ interface RoomListToastProps {
export function RoomListToast({ type, onClose }: Readonly<RoomListToastProps>): JSX.Element {
const { translate: _t } = useI18n();
let content: { text: string; icon: ComponentType<React.SVGAttributes<SVGElement>> };
let text: string;
switch (type) {
case "section_created":
content = { text: _t("room_list|section_created"), icon: CheckIcon };
text = _t("room_list|section_created");
break;
case "chat_moved":
content = { text: _t("room_list|chat_moved"), icon: CheckIcon };
text = _t("room_list|chat_moved");
break;
}
return (
<Toast className={styles.toast} Icon={content.icon} onClose={onClose} tooltip={_t("action|close")}>
{content.text}
<Toast className={styles.toast} onClose={onClose} tooltip={_t("action|close")}>
{text}
</Toast>
);
}
@@ -11,19 +11,6 @@ exports[`<RoomListToast /> > renders ChatMoved story 1`] = `
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Chat moved
</div>
<button
@@ -67,19 +54,6 @@ exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Section created
</div>
<button
@@ -16868,19 +16868,6 @@ exports[`<RoomListView /> > renders Toast story 1`] = `
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Section created
</div>
<button
@@ -37,6 +37,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemDragOverlayProps): JSX.Element => {
@@ -52,6 +53,7 @@ const RoomListItemDragOverlayWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
});
return <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
};
@@ -0,0 +1,14 @@
/*
* 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.
*/
.sectionLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
color: var(--cpd-color-text-primary);
}
@@ -8,28 +8,15 @@
import React, { type JSX } from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
import { useMockedViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot";
import { mockedActions as mockCallbacks } from "./mocked-actions";
describe("<RoomListItemMoreOptionsMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
@@ -242,6 +229,20 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
expect(mockCallbacks.onCreateSection).toHaveBeenCalled();
});
it("should call onRemoveFromSection when Remove from section is clicked", async () => {
const user = userEvent.setup();
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel({ ...defaultSnapshot, isInSection: true }, mockCallbacks);
return <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
const removeFromSection = screen.getByRole("menuitem", { name: "Remove from section" });
await user.click(removeFromSection);
expect(mockCallbacks.onRemoveFromSection).toHaveBeenCalled();
});
it("should render section items in move to section submenu", () => {
const sections = [
{ tag: "m.favourite", name: "Favourites", isSelected: false },
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, type JSX } from "react";
import React, { useMemo, useState, type JSX } from "react";
import { IconButton, Menu, MenuItem, Separator, SubMenu, ToggleMenuItem } from "@vector-im/compound-web";
import {
MarkAsReadIcon,
@@ -18,11 +18,13 @@ import {
OverflowHorizontalIcon,
ArrowRightIcon,
CheckIcon,
MinusIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../core/i18n/i18n";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
import styles from "./RoomListItemMoreOptionsMenu.module.css";
/**
* View model type for room list item
@@ -73,6 +75,8 @@ interface MoreOptionContentProps {
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
const snapshot = useViewModel(vm);
const hasSections = snapshot.sections.length > 0;
const isInSection = useMemo(() => snapshot.sections.some((section) => section.isSelected), [snapshot.sections]);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
@@ -141,6 +145,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
<MenuItem
key={section.tag}
label={section.name}
labelProps={{ className: styles.sectionLabel }}
onSelect={() => vm.onToggleSection(section.tag)}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
@@ -151,10 +156,19 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
)}
</MenuItem>
))}
<Separator />
{hasSections && <Separator />}
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
</SubMenu>
)}
{isInSection && (
<MenuItem
Icon={MinusIcon}
label={_t("room_list|more_options|remove_from_section")}
onSelect={vm.onRemoveFromSection}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
@@ -8,29 +8,16 @@
import React, { type JSX } from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import { RoomNotifState } from "./RoomNotifs";
import { useMockedViewModel } from "../../../../core/viewmodel";
import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot";
import { mockedActions as mockCallbacks } from "./mocked-actions";
describe("<RoomListItemNotificationMenu />", () => {
const mockCallbacks = {
onOpenRoom: vi.fn(),
onMarkAsRead: vi.fn(),
onMarkAsUnread: vi.fn(),
onToggleFavorite: vi.fn(),
onToggleLowPriority: vi.fn(),
onInvite: vi.fn(),
onCopyRoomLink: vi.fn(),
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel(
@@ -72,7 +72,12 @@
.dragging {
outline: 1px solid var(--cpd-color-border-interactive-hovered);
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 90%, transparent);
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 95%, transparent);
color: var(--cpd-color-text-primary);
}
.dragSource .container {
opacity: 0.6;
}
.content {
@@ -40,6 +40,7 @@ const RoomListItemWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
isSelected,
isFocused,
onFocus,
@@ -60,6 +61,7 @@ const RoomListItemWrapperImpl = ({
onSetRoomNotifState,
onCreateSection,
onToggleSection,
onRemoveFromSection,
});
return (
<RoomListItemView
@@ -125,6 +125,8 @@ export interface RoomListItemViewActions {
onCreateSection: () => void;
/** Called when toggling a room's membership in a section */
onToggleSection: (tag: string) => void;
/** Called when removing the room from a section */
onRemoveFromSection: () => void;
}
/**
@@ -150,6 +152,8 @@ export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLBut
isLastItem: boolean;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
/** Whether this item is the source of an active drag operation */
isDragSource?: boolean;
ref?: Ref<Element>;
}
@@ -165,6 +169,7 @@ export const RoomListItemView = memo(function RoomListItemView({
isFirstItem,
isLastItem,
renderAvatar,
isDragSource = false,
ref,
...props
}: RoomListItemViewProps): JSX.Element {
@@ -191,6 +196,7 @@ export const RoomListItemView = memo(function RoomListItemView({
[styles.bold]: item.isBold,
[styles.firstItem]: isFirstItem,
[styles.lastItem]: isLastItem,
[styles.dragSource]: isDragSource,
mx_RoomListItemView_selected: isSelected,
})}
gap="var(--cpd-space-3x)"
@@ -45,7 +45,7 @@ export const defaultSnapshot: RoomListItemViewSnapshot = {
},
{
tag: "element.io.section.work",
name: "Work",
name: "Work with a very long name that should be truncated",
isSelected: true,
},
{
@@ -21,4 +21,5 @@ export const mockedActions: RoomListItemViewActions = {
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
onRemoveFromSection: fn(),
};
@@ -55,12 +55,16 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({
*/
function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
const item = useViewModel(props.vm);
const { ref: draggableRef, handleRef } = useDraggable({
const {
ref: draggableRef,
handleRef,
isDragSource,
} = useDraggable({
id: item.id,
// We clone the item in the dnd overlay to avoid putting a hole in the list
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
});
const dndRef = useMergeRefs([draggableRef, handleRef]);
return <RoomListItemView {...props} ref={dndRef} />;
return <RoomListItemView {...props} ref={dndRef} isDragSource={isDragSource} />;
}
@@ -125,6 +125,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
onRemoveFromSection: fn(),
};
}
+186 -305
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -27,15 +27,16 @@ catalog:
# fonts
"@fontsource/inter": 5.2.8
# vite
vite: 8.0.15
vite: 8.0.16
vite-plugin-node-polyfills: 0.28.0
vite-plugin-svgr: 5.2.0
vitest: 4.1.7
"@vitest/coverage-v8": 4.1.7
"@vitest/browser-playwright": 4.1.7
vitest: 4.1.8
"@vitest/coverage-v8": 4.1.8
"@vitest/browser-playwright": 4.1.8
"@vitejs/plugin-react": 6.0.2
unplugin-dts: 1.0.1
packageExtensions:
fdir:
dependencies: