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