diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index b48345387f..29f95276fb 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -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"); + }); }); }); diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index 2f2f953ce6..b03b2e32ec 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -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); diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 823efba081..215fcb8013 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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", diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx index 2edac31c84..d84a7a6d42 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx @@ -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 ; }; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.module.css new file mode 100644 index 0000000000..8956cab580 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.module.css @@ -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); +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx index a6d657b2a5..0b641fb146 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx @@ -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("", () => { - 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 = {}): ReturnType => { const TestComponent = (): JSX.Element => { const vm = useMockedViewModel( @@ -242,6 +229,20 @@ describe("", () => { 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 ; + }; + render(); + + 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 }, diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx index 00690a445b..9208f41b03 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx @@ -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
e.stopPropagation()}> @@ -141,6 +145,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { vm.onToggleSection(section.tag)} onClick={(evt) => evt.stopPropagation()} hideChevron={true} @@ -151,10 +156,19 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { )} ))} - + {hasSections && } )} + {isInSection && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} ", () => { - 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 => { const TestComponent = (): JSX.Element => { const vm = useMockedViewModel( diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.stories.tsx index 21bf2806d2..0dba75cd1b 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.stories.tsx @@ -40,6 +40,7 @@ const RoomListItemWrapperImpl = ({ onSetRoomNotifState, onCreateSection, onToggleSection, + onRemoveFromSection, isSelected, isFocused, onFocus, @@ -60,6 +61,7 @@ const RoomListItemWrapperImpl = ({ onSetRoomNotifState, onCreateSection, onToggleSection, + onRemoveFromSection, }); return ( 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; } /** diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/default-snapshot.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/default-snapshot.ts index bf0cb0189e..4a7a6afe56 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/default-snapshot.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/default-snapshot.ts @@ -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, }, { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/mocked-actions.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/mocked-actions.ts index 8e79c52cc5..846f433440 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/mocked-actions.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/mocked-actions.ts @@ -21,4 +21,5 @@ export const mockedActions: RoomListItemViewActions = { onSetRoomNotifState: fn(), onCreateSection: fn(), onToggleSection: fn(), + onRemoveFromSection: fn(), }; diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx index 3674aef3b7..806864eaee 100644 --- a/packages/shared-components/src/room-list/story-mocks.tsx +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -125,6 +125,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index: onSetRoomNotifState: fn(), onCreateSection: fn(), onToggleSection: fn(), + onRemoveFromSection: fn(), }; }