From b122594ee363557d21f13d9e8ef29efce24492ff Mon Sep 17 00:00:00 2001 From: mjkatgithub Date: Sat, 13 Jun 2026 12:34:04 +0200 Subject: [PATCH 1/2] Implement leave room functionality in chat components - Added a confirmation dialog for leaving Matrix rooms, including a new `LeaveRoomConfirmPanel` component. - Updated `RoomCategoryList` to include leave room options in the action menu. - Enhanced `useMatrixClient` to support leaving rooms and cleaning up direct message references. - Introduced internationalization support for leave room messages in English and German. - Updated `CHANGELOG` to reflect the new leave room feature and related changes. --- CHANGELOG.md | 4 + app/components/Chat/LeaveRoomConfirmPanel.vue | 98 ++++++++++++++ app/components/Chat/RoomCategoryList.vue | 27 +++- app/composables/chat/useChatRoomSidebar.ts | 29 ++++ app/composables/chat/useChatSpaceRail.ts | 14 ++ app/composables/i18n/locales/de.ts | 7 + app/composables/i18n/locales/en.ts | 7 + app/composables/matrix/roomsOrDirectory.ts | 68 ++++++++++ app/composables/useMatrixClient.ts | 10 +- app/pages/chat.vue | 49 +++++++ app/utils/matrixRoomChannelPermissions.ts | 6 + docs/architecture.md | 6 +- .../components/Chat/RoomCategoryList.spec.ts | 21 +++ .../matrix/removeDirectAccountData.spec.ts | 125 ++++++++++++++++++ .../composables/useMatrixClient.rooms.spec.ts | 67 ++++++++++ 15 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 app/components/Chat/LeaveRoomConfirmPanel.vue create mode 100644 tests/unit/composables/matrix/removeDirectAccountData.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6f10e..5b4681a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Leave Matrix rooms from the chat sidebar: confirmation dialog, + `leaveRoom` via matrix-js-sdk, `m.direct` cleanup for DMs, neutral + chat view after leaving the active room + ([#94](https://github.com/mjkatgithub/Decentra/issues/94)) - Project architecture map for developers and agents: `docs/architecture.md`, `docs/README.md`, issue documentation checklist; `AGENTS.md` requires doc updates on structural changes diff --git a/app/components/Chat/LeaveRoomConfirmPanel.vue b/app/components/Chat/LeaveRoomConfirmPanel.vue new file mode 100644 index 0000000..06000f2 --- /dev/null +++ b/app/components/Chat/LeaveRoomConfirmPanel.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/components/Chat/RoomCategoryList.vue b/app/components/Chat/RoomCategoryList.vue index 382c93f..c9d7321 100644 --- a/app/components/Chat/RoomCategoryList.vue +++ b/app/components/Chat/RoomCategoryList.vue @@ -39,6 +39,7 @@ const props = defineProps<{ resolveRoomInsertIndex?: (category: RoomSectionItem) => number | undefined canInviteToRoom?: (roomId: string) => boolean canOpenRoomSettings?: (roomId: string) => boolean + canLeaveRoom?: (roomId: string) => boolean canInviteToSpace?: boolean /** When null (e.g. Home), hierarchy DnD is off */ selectedRootSpaceId: string | null @@ -63,6 +64,7 @@ const emit = defineEmits<{ inviteSpace: [] inviteRoom: [roomId: string] openRoomSettings: [roomId: string] + leaveRoom: [roomId: string] setRoomNotification: [payload: { roomId: string; level: RoomNotificationLevel }] setSpaceNotification: [level: RoomNotificationLevel] openHomeStartDm: [] @@ -418,34 +420,47 @@ function roomNotificationMenuItems(roomId: string) { function showRoomActionMenu(roomId: string): boolean { return Boolean( props.canInviteToRoom?.(roomId) || - props.canOpenRoomSettings?.(roomId), + props.canOpenRoomSettings?.(roomId) || + props.canLeaveRoom?.(roomId), ) } function roomActionMenuItems(roomId: string) { - const items: Array<{ + const primaryItems: Array<{ label: string icon: string onSelect: () => void }> = [] if (props.canInviteToRoom?.(roomId)) { - items.push({ + primaryItems.push({ label: translateText('invite.roomMenu'), icon: 'i-lucide-user-plus', onSelect: () => emit('inviteRoom', roomId), }) } if (props.canOpenRoomSettings?.(roomId)) { - items.push({ + primaryItems.push({ label: translateText('layout.openRoomSettings'), icon: 'i-lucide-settings-2', onSelect: () => emit('openRoomSettings', roomId), }) } - if (items.length === 0) { + if (primaryItems.length === 0 && !props.canLeaveRoom?.(roomId)) { return [] } - return [items] + if (!props.canLeaveRoom?.(roomId)) { + return [primaryItems] + } + const leaveItem = { + label: translateText('layout.leaveRoomMenu'), + icon: 'i-lucide-log-out', + color: 'error' as const, + onSelect: () => emit('leaveRoom', roomId), + } + if (primaryItems.length === 0) { + return [[leaveItem]] + } + return [primaryItems, [leaveItem]] } function spaceNotificationItems() { diff --git a/app/composables/chat/useChatRoomSidebar.ts b/app/composables/chat/useChatRoomSidebar.ts index 45d0523..781a3ae 100644 --- a/app/composables/chat/useChatRoomSidebar.ts +++ b/app/composables/chat/useChatRoomSidebar.ts @@ -46,6 +46,7 @@ export function useChatRoomSidebar(options: { refreshRooms: () => void; }) { const inviteTarget = ref<{ roomId: string; label: string } | null>(null); + const leaveTarget = ref<{ roomId: string; label: string } | null>(null); const canInviteToSpace = computed(() => { const spaceId = options.selectedSpaceId.value; @@ -93,6 +94,18 @@ export function useChatRoomSidebar(options: { return isJoinedRoom(options.client.value, roomId); } + function canLeaveRoom(roomId: string): boolean { + const matrixClient = options.client.value; + if (!isJoinedRoom(matrixClient, roomId)) { + return false; + } + const matrixRoom = matrixClient?.getRoom(roomId); + if (!matrixRoom) { + return false; + } + return getRoomType(matrixRoom) !== "m.space"; + } + function openRoomSettings(roomId: string) { const query: Record = { room: roomId }; const rootId = options.selectedSpaceId.value; @@ -128,6 +141,18 @@ export function useChatRoomSidebar(options: { inviteTarget.value = null; } + function openLeaveRoom(roomId: string) { + const matrixClient = options.client.value; + const label = + matrixClient?.getRoom(roomId)?.name || + options.translateText("layout.roomFallback"); + leaveTarget.value = { roomId, label }; + } + + function closeLeaveOverlay() { + leaveTarget.value = null; + } + function buildHomeSections(): RoomCategoryGroup[] { const personalRooms = options.visibleRoomsForSidebar.value.filter( (room) => @@ -383,13 +408,17 @@ export function useChatRoomSidebar(options: { return { inviteTarget, + leaveTarget, canInviteToSpace, canInviteToRoom, canOpenRoomSettings, + canLeaveRoom, openRoomSettings, openInviteToRoom, openInviteToSpace, closeInviteOverlay, + openLeaveRoom, + closeLeaveOverlay, buildHomeSections, buildSpaceSections, canManageChildrenOnSpace, diff --git a/app/composables/chat/useChatSpaceRail.ts b/app/composables/chat/useChatSpaceRail.ts index e170246..efa06c3 100644 --- a/app/composables/chat/useChatSpaceRail.ts +++ b/app/composables/chat/useChatSpaceRail.ts @@ -38,6 +38,7 @@ export function useChatSpaceRail(options: { >; allMessages: Ref; messages: Ref; + suppressAutoRoomSelect?: Ref; }) { const joinedSpaceIds = computed(() => getJoinedSpaceRoomIds( @@ -258,6 +259,19 @@ export function useChatSpaceRail(options: { const selectedExists = rooms.some( (room) => room.roomId === options.selectedRoomId.value, ); + if ( + options.suppressAutoRoomSelect?.value && + options.selectedRoomId.value === null + ) { + return; + } + if (!selectedExists && options.selectedRoomId.value !== null) { + const firstRoom = rooms[0]; + if (firstRoom) { + options.selectedRoomId.value = firstRoom.roomId; + } + return; + } if (!selectedExists) { const firstRoom = rooms[0]; if (firstRoom) { diff --git a/app/composables/i18n/locales/de.ts b/app/composables/i18n/locales/de.ts index 36a1792..78845f3 100644 --- a/app/composables/i18n/locales/de.ts +++ b/app/composables/i18n/locales/de.ts @@ -246,6 +246,13 @@ const messages: LocaleMessages = { 'layout.openAccountSettings': 'Account-Einstellungen', 'layout.openSpaceSettings': 'Space-Einstellungen', 'layout.openRoomSettings': 'Kanal-Einstellungen', + 'layout.leaveRoomMenu': 'Kanal verlassen', + 'layout.leaveRoomConfirmTitle': 'Kanal verlassen?', + 'layout.leaveRoomConfirmBody': + 'Du verlässt {name}. Ein erneuter Beitritt ist nur mit Einladung möglich.', + 'layout.leaveRoomConfirmAction': 'Verlassen', + 'layout.leaveRoomCancel': 'Abbrechen', + 'layout.leaveRoomFailed': 'Kanal konnte nicht verlassen werden', 'layout.roomActionsMenu': 'Kanaloptionen', 'layout.roomNotifications': 'Benachrichtigungen', 'layout.spaceNotifications': 'Space-Benachrichtigungen', diff --git a/app/composables/i18n/locales/en.ts b/app/composables/i18n/locales/en.ts index 36e0ace..c91a033 100644 --- a/app/composables/i18n/locales/en.ts +++ b/app/composables/i18n/locales/en.ts @@ -226,6 +226,13 @@ const messages: LocaleMessages = { 'layout.openAccountSettings': 'Account settings', 'layout.openSpaceSettings': 'Space settings', 'layout.openRoomSettings': 'Channel settings', + 'layout.leaveRoomMenu': 'Leave channel', + 'layout.leaveRoomConfirmTitle': 'Leave channel?', + 'layout.leaveRoomConfirmBody': + 'You will leave {name}. You can rejoin only if invited again.', + 'layout.leaveRoomConfirmAction': 'Leave', + 'layout.leaveRoomCancel': 'Cancel', + 'layout.leaveRoomFailed': 'Could not leave channel', 'layout.roomActionsMenu': 'Channel options', 'layout.roomNotifications': 'Notifications', 'layout.spaceNotifications': 'Space notifications', diff --git a/app/composables/matrix/roomsOrDirectory.ts b/app/composables/matrix/roomsOrDirectory.ts index 8421db0..65ba491 100644 --- a/app/composables/matrix/roomsOrDirectory.ts +++ b/app/composables/matrix/roomsOrDirectory.ts @@ -50,6 +50,44 @@ import type { UserDirectoryResultItem, } from './matrixClientTypes' +function readDirectAccountContent( + matrixClient: MatrixClient, +): Record { + const directEvent = matrixClient.getAccountData(EventType.Direct) + return (directEvent?.getContent() as + | Record + | undefined) ?? {} +} + +export function isRoomListedInDirectAccountData( + matrixClient: MatrixClient, + roomId: string, +): boolean { + const content = readDirectAccountContent(matrixClient) + return Object.values(content).some((ids) => ids?.includes(roomId)) +} + +export async function removeDirectAccountData( + matrixClient: MatrixClient, + roomId: string, +): Promise { + const previous = readDirectAccountContent(matrixClient) + let changed = false + const next: Record = {} + for (const [peerUserId, roomIds] of Object.entries(previous)) { + const filtered = (roomIds ?? []).filter((id) => id !== roomId) + if (filtered.length !== (roomIds?.length ?? 0)) { + changed = true + } + if (filtered.length > 0) { + next[peerUserId] = filtered + } + } + if (changed) { + await matrixClient.setAccountData(EventType.Direct, next) + } +} + export async function mergeDirectAccountData( matrixClient: MatrixClient, peerUserId: string, @@ -311,6 +349,36 @@ export async function joinRoomByIdOrAlias( } } +export async function leaveRoom( + matrixClient: MatrixClient, + roomId: string, +): Promise { + const trimmed = roomId.trim() + if (!trimmed) { + throw new Error('Room id is required') + } + const shouldPruneDirect = isRoomListedInDirectAccountData( + matrixClient, + trimmed, + ) + try { + await matrixClient.leave(trimmed) + if (shouldPruneDirect) { + await removeDirectAccountData(matrixClient, trimmed) + } + try { + await matrixClient.forget(trimmed) + } catch { + // Left rooms may linger until sync; getRooms() filters non-join. + } + } catch (error) { + if (isTransportFailureWithoutMatrixBody(error)) { + throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) + } + throwMappedMatrixError(error, 'Could not leave room') + } +} + export async function searchPublicRooms( matrixClient: MatrixClient, options: { diff --git a/app/composables/useMatrixClient.ts b/app/composables/useMatrixClient.ts index a05d4ee..0350945 100644 --- a/app/composables/useMatrixClient.ts +++ b/app/composables/useMatrixClient.ts @@ -1,5 +1,6 @@ import type { MatrixClient } from 'matrix-js-sdk' import * as sdk from 'matrix-js-sdk' +import { matrixRoomHasJoinedMembership } from '~/utils/matrixRoomChannelPermissions' import { HOMESERVER_CONNECTION_HINT_ERROR, isSameHomeserver, @@ -497,7 +498,9 @@ export function useMatrixClient() { if (!client.value) { return [] } - return client.value.getRooms() + return client.value + .getRooms() + .filter((room) => matrixRoomHasJoinedMembership(room)) } function getRoom(roomId: string): sdk.Room | null { @@ -853,6 +856,10 @@ export function useMatrixClient() { ) } + async function leaveRoom(roomId: string): Promise { + return matrixRooms.leaveRoom(requireClient(), roomId) + } + async function searchPublicRooms(options: { searchTerm?: string limit?: number @@ -912,6 +919,7 @@ export function useMatrixClient() { createMatrixSpace, inviteUsersToRoom, joinRoomByIdOrAlias, + leaveRoom, searchPublicRooms, searchUsersDirectory, reorderSpaceChildren, diff --git a/app/pages/chat.vue b/app/pages/chat.vue index 54630bd..0485a2f 100644 --- a/app/pages/chat.vue +++ b/app/pages/chat.vue @@ -16,6 +16,7 @@ import ChatOnboardingPanel from "~/components/Chat/Onboarding/ChatOnboardingPane import ChatDmStartPanel from "~/components/Chat/Onboarding/ChatDmStartPanel.vue"; import ChatPublicRoomsPanel from "~/components/Chat/Onboarding/ChatPublicRoomsPanel.vue"; import MatrixInvitePanel from "~/components/Chat/MatrixInvitePanel.vue"; +import LeaveRoomConfirmPanel from "~/components/Chat/LeaveRoomConfirmPanel.vue"; import { canUserSendRoomMessage } from "~/utils/matrixRoomMessagePermissions"; import { canUserPinEvents } from "~/utils/matrixRoomPinnedEventsPermissions"; import { canPerformSpaceRoleAction } from "~/utils/decentraSpaceRolesPermissions"; @@ -71,6 +72,7 @@ const selectedSpaceId = useState( () => null, ); const pendingRootSpaceId = ref(null); +const suppressAutoRoomSelect = ref(false); const matrixRooms = ref>>([]); const spaceUnreadForRail = shallowRef< Record< @@ -156,6 +158,7 @@ const spaceRail = useChatSpaceRail({ spaceUnreadById: spaceUnreadForRail, allMessages, messages, + suppressAutoRoomSelect, }); const { @@ -306,13 +309,17 @@ const roomSidebar = useChatRoomSidebar({ const { inviteTarget, + leaveTarget, canInviteToSpace, canInviteToRoom, canOpenRoomSettings, + canLeaveRoom, openRoomSettings, openInviteToRoom, openInviteToSpace, closeInviteOverlay, + openLeaveRoom, + closeLeaveOverlay, buildHomeSections, buildSpaceSections, canManageChildrenOnSpace, @@ -327,6 +334,30 @@ const { openAddSubspaceToSpace, } = roomSidebar; +function navigateToChatHome() { + suppressAutoRoomSelect.value = true; + onboardingSubView.value = null; + selectedSpaceId.value = HOME_SPACE_ID; + selectedRoomId.value = null; + closeActiveThread(); + resetTimelineState(); + closeRoomThreadsPanel(); + closePinnedMessagesPanel(); +} + +function onLeaveConfirming(roomId: string) { + if (selectedRoomId.value === roomId) { + navigateToChatHome(); + } +} + +function onRoomLeft(_leftRoomId: string) { + // Interim (#94): always Home onboarding. #124: space home when in a space. + navigateToChatHome(); + refreshRooms(); + closeLeaveOverlay(); +} + const { spaceUnreadById } = useSpaceUnreadById({ matrixSyncPrepared, unreadByRoomId, @@ -544,6 +575,7 @@ const { } = pageShell; function selectRoom(roomId: string) { + suppressAutoRoomSelect.value = false; pageShell.selectRoom(roomId, activeThread.value, closeActiveThread); } @@ -635,10 +667,12 @@ setupMatrixEventWatchers(); :can-invite-to-space="canInviteToSpace" :can-invite-to-room="canInviteToRoom" :can-open-room-settings="canOpenRoomSettings" + :can-leave-room="canLeaveRoom" :resolve-room-insert-index="resolveRoomInsertIndexForCategory" @invite-space="openInviteToSpace" @invite-room="openInviteToRoom" @open-room-settings="openRoomSettings" + @leave-room="openLeaveRoom" @open-home-start-dm="openHomeStartDm" @open-home-create-room="openHomeCreateRoom" @open-home-explore-public="openHomeExplorePublic" @@ -920,5 +954,20 @@ setupMatrixEventWatchers(); @invited="closeInviteOverlay" /> + +
+ +
diff --git a/app/utils/matrixRoomChannelPermissions.ts b/app/utils/matrixRoomChannelPermissions.ts index aaf7047..798d1a7 100644 --- a/app/utils/matrixRoomChannelPermissions.ts +++ b/app/utils/matrixRoomChannelPermissions.ts @@ -61,5 +61,11 @@ export function isJoinedRoom( return false } const room = matrixClient.getRoom(roomId) + return matrixRoomHasJoinedMembership(room) +} + +export function matrixRoomHasJoinedMembership( + room: { getMyMembership?: () => string } | null | undefined, +): boolean { return room?.getMyMembership?.() === 'join' } diff --git a/docs/architecture.md b/docs/architecture.md index f866589..dd59642 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ re-exports from `app/composables/matrix/*`. | `sessionCrypto.ts` | localStorage session/device, Rust crypto WASM, verification | | `recoveryKeyBootstrap.ts` | Recovery key → cross-signing bootstrap | | `messages.ts` | Send, edit, reactions, media messages | -| `roomsOrDirectory.ts` | Rooms, spaces, directory, DMs, invites | +| `roomsOrDirectory.ts` | Rooms, spaces, directory, DMs, invites, leave | | `mediaUpload.ts` | Upload helpers | | `matrixRegistrationUia.ts` | Legacy `/register` UIA loop | | `signupUiaCore.ts`, `signupUiaFlows.ts` | UIA flow selection | @@ -196,7 +196,7 @@ E2E: `tests/e2e/features/chat.feature`, `step-definitions/unread-channel.steps.m | --- | --- | | Category / room hierarchy | `app/utils/spaceRoomCategories.ts`, `homeRoomCategories.ts` | | Space rail | `useChatSpaceRail.ts` → `SpaceList.vue` | -| Room sidebar | `useChatRoomSidebar.ts` → `RoomList.vue`, `RoomCategoryList.vue` | +| Room sidebar | `useChatRoomSidebar.ts` → `RoomList.vue`, `RoomCategoryList.vue` (invite, settings, leave via ellipsis menu) | | Space roles | `decentraSpaceRoles.ts`, `decentraSpaceRolesPermissions.ts`, `spaceRolesMatrixSync.ts` | | Settings composables | `useSpaceRoles.ts`, `useSpaceSettings.ts`, `useSpaceMembers.ts` | | New space page | [`app/pages/spaces/new.vue`](../app/pages/spaces/new.vue) | @@ -251,7 +251,7 @@ README). | --- | --- | | Matrix send / edit / reactions | `app/composables/matrix/messages.ts`; exports in `useMatrixClient.ts` | | New message type / timeline UI | `app/utils/chatTimeline/*`, `MessageItem.vue` | -| New room or space action | `matrix/roomsOrDirectory.ts`, `useChatRoomSidebar.ts` | +| New room or space action | `matrix/roomsOrDirectory.ts`, `useChatRoomSidebar.ts`, `LeaveRoomConfirmPanel.vue` | | Unread or space-rail badge | `roomUnread.ts`, `spaceUnread.ts`, `useSpaceUnreadById.ts` | | Push / sound / browser tab title | `incomingMessageNotify.ts`, `documentTitle.ts`, wiring in `chat.vue` | | i18n string | `app/composables/i18n/locales/en.ts` and `de.ts` | diff --git a/tests/unit/components/Chat/RoomCategoryList.spec.ts b/tests/unit/components/Chat/RoomCategoryList.spec.ts index cfe0466..92b872e 100644 --- a/tests/unit/components/Chat/RoomCategoryList.spec.ts +++ b/tests/unit/components/Chat/RoomCategoryList.spec.ts @@ -231,4 +231,25 @@ describe('RoomCategoryList', () => { wrapper.find('button[aria-label="Invite to channel"]').exists() .should.equal(false) }) + + it('shows room actions when only leave is allowed', () => { + const wrapper = mountCategoryList( + [ + { + id: 'dms', + name: 'Personal', + canReorderRooms: false, + rooms: [{ roomId: '!dm:example.org', name: 'Alice' }], + }, + ], + { + canLeaveRoom: () => true, + canInviteToRoom: () => false, + canOpenRoomSettings: () => false, + }, + ) + + wrapper.find('button[data-room-actions="!dm:example.org"]').exists() + .should.equal(true) + }) }) diff --git a/tests/unit/composables/matrix/removeDirectAccountData.spec.ts b/tests/unit/composables/matrix/removeDirectAccountData.spec.ts new file mode 100644 index 0000000..b5f50b7 --- /dev/null +++ b/tests/unit/composables/matrix/removeDirectAccountData.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType } from 'matrix-js-sdk' +import { + isRoomListedInDirectAccountData, + leaveRoom, + removeDirectAccountData, +} from '~/composables/matrix/roomsOrDirectory' + +describe('removeDirectAccountData', () => { + it('removes room id from m.direct map', async () => { + const setAccountData = vi.fn(async () => undefined) + const matrixClient = { + getAccountData: vi.fn((type: string) => { + if (type === EventType.Direct) { + return { + getContent: () => ({ + '@bob:example.org': ['!dm:example.org', '!other:example.org'], + '@carol:example.org': ['!dm:example.org'], + }), + } + } + return undefined + }), + setAccountData, + } + + await removeDirectAccountData(matrixClient as never, '!dm:example.org') + + expect(setAccountData).toHaveBeenCalledWith(EventType.Direct, { + '@bob:example.org': ['!other:example.org'], + }) + }) + + it('skips setAccountData when room is not listed', async () => { + const setAccountData = vi.fn(async () => undefined) + const matrixClient = { + getAccountData: vi.fn(() => ({ + getContent: () => ({ + '@bob:example.org': ['!other:example.org'], + }), + })), + setAccountData, + } + + await removeDirectAccountData(matrixClient as never, '!missing:example.org') + + expect(setAccountData).not.toHaveBeenCalled() + }) +}) + +describe('isRoomListedInDirectAccountData', () => { + it('returns true when room id is present', () => { + const matrixClient = { + getAccountData: vi.fn(() => ({ + getContent: () => ({ + '@bob:example.org': ['!dm:example.org'], + }), + })), + } + + expect( + isRoomListedInDirectAccountData( + matrixClient as never, + '!dm:example.org', + ), + ).toBe(true) + }) +}) + +describe('leaveRoom', () => { + it('calls client.leave with room id', async () => { + const leave = vi.fn(async () => undefined) + const forget = vi.fn(async () => undefined) + const matrixClient = { + getAccountData: vi.fn(() => undefined), + leave, + forget, + setAccountData: vi.fn(), + } + + await leaveRoom(matrixClient as never, '!room:example.org') + + expect(leave).toHaveBeenCalledWith('!room:example.org') + expect(forget).toHaveBeenCalledWith('!room:example.org') + }) + + it('prunes m.direct after a successful leave', async () => { + const leave = vi.fn(async () => undefined) + const forget = vi.fn(async () => undefined) + const setAccountData = vi.fn(async () => undefined) + const matrixClient = { + getAccountData: vi.fn(() => ({ + getContent: () => ({ + '@bob:example.org': ['!dm:example.org'], + }), + })), + leave, + forget, + setAccountData, + } + + await leaveRoom(matrixClient as never, '!dm:example.org') + + expect(leave).toHaveBeenCalledWith('!dm:example.org') + expect(setAccountData).toHaveBeenCalledWith(EventType.Direct, {}) + expect(forget).toHaveBeenCalledWith('!dm:example.org') + }) + + it('maps forbidden errors to user-visible messages', async () => { + const matrixClient = { + getAccountData: vi.fn(() => undefined), + leave: vi.fn(async () => { + throw { + errcode: 'M_FORBIDDEN', + error: 'You are not allowed to leave this room', + } + }), + setAccountData: vi.fn(), + } + + await expect( + leaveRoom(matrixClient as never, '!room:example.org'), + ).rejects.toThrow('You are not allowed to leave this room') + }) +}) diff --git a/tests/unit/composables/useMatrixClient.rooms.spec.ts b/tests/unit/composables/useMatrixClient.rooms.spec.ts index f05df71..6a0f4ac 100644 --- a/tests/unit/composables/useMatrixClient.rooms.spec.ts +++ b/tests/unit/composables/useMatrixClient.rooms.spec.ts @@ -313,4 +313,71 @@ describe('getOrCreateDirectMessageRoom', () => { expect(setAccountData).toHaveBeenCalled() }) }) + +describe('leaveRoom via useMatrixClient', () => { + it('delegates to matrix client leave', async () => { + const leave = vi.fn(async () => undefined) + const forget = vi.fn(async () => undefined) + const matrixClient = { + getUserId: () => '@alice:example.org', + getAccountData: vi.fn(() => undefined), + getRooms: vi.fn(() => []), + getRoom: vi.fn(() => null), + leave, + forget, + setAccountData: vi.fn(), + startClient: vi.fn(), + initRustCrypto: vi.fn(async () => undefined), + getCrypto: () => null, + getDeviceId: () => 'DEV', + } + const authClient = { + loginRequest: vi.fn(async () => ({ + access_token: 't', + user_id: '@alice:example.org', + device_id: 'DEV', + })), + } + createClient + .mockReturnValueOnce(authClient) + .mockReturnValueOnce(matrixClient) + + const { useMatrixClient } = await import('~/composables/useMatrixClient') + const { login, leaveRoom } = useMatrixClient() + await login('https://example.org', 'alice', 'pw') + await leaveRoom('!room:example.org') + expect(leave).toHaveBeenCalledWith('!room:example.org') + expect(forget).toHaveBeenCalledWith('!room:example.org') + }) + + it('getRooms omits rooms the user has left', async () => { + const joinedRoom = { roomId: '!joined:example.org', getMyMembership: () => 'join' } + const leftRoom = { roomId: '!left:example.org', getMyMembership: () => 'leave' } + const matrixClient = { + getUserId: () => '@alice:example.org', + getAccountData: vi.fn(() => undefined), + getRooms: vi.fn(() => [joinedRoom, leftRoom]), + getRoom: vi.fn(() => null), + startClient: vi.fn(), + initRustCrypto: vi.fn(async () => undefined), + getCrypto: () => null, + getDeviceId: () => 'DEV', + } + const authClient = { + loginRequest: vi.fn(async () => ({ + access_token: 't', + user_id: '@alice:example.org', + device_id: 'DEV', + })), + } + createClient + .mockReturnValueOnce(authClient) + .mockReturnValueOnce(matrixClient) + + const { useMatrixClient } = await import('~/composables/useMatrixClient') + const { login, getRooms } = useMatrixClient() + await login('https://example.org', 'alice', 'pw') + expect(getRooms().map((room) => room.roomId)).toEqual(['!joined:example.org']) + }) +}) }) From 8b0224ea7f538b12be7dd5ac57c0ab89145b466c Mon Sep 17 00:00:00 2001 From: mjkatgithub Date: Sat, 13 Jun 2026 13:31:19 +0200 Subject: [PATCH 2/2] Implement leave room E2E tests and enhance seeding script - Added Cucumber E2E tests for leaving side seeded group rooms, space channels, and direct message rooms, ensuring proper functionality and user experience. - Enhanced the runtime seeding script to create and manage direct message rooms, improving the testing environment setup. - Updated CHANGELOG to reflect the addition of new E2E tests and improvements in the seeding process. --- CHANGELOG.md | 3 +- tests/e2e/features/chat.feature | 39 ++++ tests/e2e/scripts/runtime-seed-synapse.mjs | 37 ++++ .../e2e/step-definitions/leave-room.steps.mjs | 188 ++++++++++++++++++ tests/e2e/support/hooks.mjs | 23 ++- 5 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/step-definitions/leave-room.steps.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4681a..a9133d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Leave Matrix rooms from the chat sidebar: confirmation dialog, `leaveRoom` via matrix-js-sdk, `m.direct` cleanup for DMs, neutral - chat view after leaving the active room + chat view after leaving the active room; Cucumber E2E for ephemeral + group, space channel, and DM leave flows ([#94](https://github.com/mjkatgithub/Decentra/issues/94)) - Project architecture map for developers and agents: `docs/architecture.md`, `docs/README.md`, issue documentation diff --git a/tests/e2e/features/chat.feature b/tests/e2e/features/chat.feature index fc3fa26..a06d3a2 100644 --- a/tests/e2e/features/chat.feature +++ b/tests/e2e/features/chat.feature @@ -309,3 +309,42 @@ Feature: Chat | online | | away | | offline | + + @leave_room + Scenario: Leave side seeded group room from sidebar + When I open the login page + And I sign in with configured credentials + And I select Home in the space rail + And I open the side seeded test room for leave + And I open the channel options menu for the side seeded test room + And I choose leave channel from the menu + And I confirm leaving the channel + Then I should see the chat home onboarding panel + And I should not be able to send messages in chat + And the side seeded test room should not appear in the sidebar + When I reload the current page + Then the side seeded test room should not appear in the sidebar + + @leave_room + Scenario: Leave seeded space channel from sidebar + When I open the login page + And I sign in with configured credentials + And I select the seeded test space in the space rail + And I open the seeded test space channel + And I open the channel options menu for the seeded test space channel + And I choose leave channel from the menu + And I confirm leaving the channel + Then I should see the chat home onboarding panel + And the seeded test space channel should not appear in the sidebar + + @leave_room + Scenario: Leave seeded direct message room from sidebar + When I open the login page + And I sign in with configured credentials + And I select Home in the space rail + And I open the leave test dm room + And I open the channel options menu for the leave test dm room + And I choose leave channel from the menu + And I confirm leaving the channel + Then I should see the chat home onboarding panel + And the leave test dm room should not appear in the sidebar diff --git a/tests/e2e/scripts/runtime-seed-synapse.mjs b/tests/e2e/scripts/runtime-seed-synapse.mjs index 65ffc65..d49892b 100644 --- a/tests/e2e/scripts/runtime-seed-synapse.mjs +++ b/tests/e2e/scripts/runtime-seed-synapse.mjs @@ -313,6 +313,41 @@ async function main() { body: 'E2E_POST_UNDECRYPTABLE_MESSAGE' }) + const leaveDmRoomResponse = await withAuth( + primarySession.access_token, + '/_matrix/client/v3/createRoom', + { + method: 'POST', + body: JSON.stringify({ + invite: [secondarySession.user_id], + is_direct: true, + preset: 'trusted_private_chat', + }), + }, + ) + const leaveDmRoomId = leaveDmRoomResponse.room_id + await withAuth( + secondarySession.access_token, + `/_matrix/client/v3/rooms/${encodeURIComponent(leaveDmRoomId)}/join`, + { method: 'POST', body: '{}' }, + ) + await withAuth( + primarySession.access_token, + `/_matrix/client/v3/user/${encodeURIComponent(primarySession.user_id)}/account_data/m.direct`, + { + method: 'PUT', + body: JSON.stringify({ + [secondarySession.user_id]: [leaveDmRoomId], + }), + }, + ) + await sendMessage( + primarySession.access_token, + leaveDmRoomId, + 'seed-leave-dm', + { msgtype: 'm.text', body: 'E2E_LEAVE_DM_SEED' }, + ) + const generatedEnvPath = resolve(workspaceRoot, 'tests/e2e/.env.e2e.generated') mkdirSync(dirname(generatedEnvPath), { recursive: true }) const generatedEnv = [ @@ -329,7 +364,9 @@ async function main() { `E2E_SIDE_TEST_ROOM_ID=${sideRoomId}`, `E2E_TEST_SPACE_NAME=${spaceName}`, `E2E_TEST_SPACE_ID=${spaceId}`, + `E2E_TEST_SPACE_CHANNEL_NAME=${spaceChannelName}`, `E2E_TEST_SPACE_CHANNEL_ID=${spaceChannelId}`, + `E2E_LEAVE_DM_ROOM_ID=${leaveDmRoomId}`, ].join('\n') writeFileSync(generatedEnvPath, `${generatedEnv}\n`, 'utf8') console.log('Synapse E2E seeding completed') diff --git a/tests/e2e/step-definitions/leave-room.steps.mjs b/tests/e2e/step-definitions/leave-room.steps.mjs new file mode 100644 index 0000000..76e2c7a --- /dev/null +++ b/tests/e2e/step-definitions/leave-room.steps.mjs @@ -0,0 +1,188 @@ +import { When, Then } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +function requireEnv(variableName) { + const variableValue = process.env[variableName] + if (!variableValue) { + throw new Error(`Missing required E2E env: ${variableName}`) + } + return variableValue +} + +function sideTestRoomId() { + return requireEnv('E2E_SIDE_TEST_ROOM_ID') +} + +function seededSpaceChannelId() { + return requireEnv('E2E_TEST_SPACE_CHANNEL_ID') +} + +function leaveDmRoomId() { + return requireEnv('E2E_LEAVE_DM_ROOM_ID') +} + +function roomButtonById(page, roomId) { + return page.locator(`button[data-room-id="${roomId}"]`).first() +} + +async function openChannelOptionsMenu(page, roomId) { + const roomButton = roomButtonById(page, roomId) + await expect(roomButton).toBeVisible({ timeout: 20000 }) + await roomButton.hover() + const actionsButton = page + .locator(`button[data-room-actions="${roomId}"]`) + .first() + await expect(actionsButton).toBeVisible({ timeout: 10000 }) + await actionsButton.click() +} + +async function chooseLeaveChannelFromMenu(page) { + const leavePattern = /Leave channel|Kanal verlassen/i + const menuItem = page.getByRole('menuitem', { name: leavePattern }) + if (await menuItem.count() > 0) { + await menuItem.first().click() + return + } + const menuButton = page.getByRole('button', { name: leavePattern }) + if (await menuButton.count() > 0) { + await menuButton.first().click() + return + } + await page.getByText(leavePattern).first().click() +} + +async function confirmLeaveChannel(page) { + await expect( + page.getByRole('heading', { + name: /Leave channel\?|Kanal verlassen\?/i, + }), + ).toBeVisible({ timeout: 10000 }) + await page.getByRole('button', { name: /^(Leave|Verlassen)$/i }).click() +} + +async function assertChatHomeOnboarding(page) { + const onboardingHeading = page.getByRole('heading', { + name: /Get started|Erste Schritte/i, + }) + const startDmButton = page.getByRole('button', { + name: /Start direct message|Direktnachricht starten/i, + }) + await expect(onboardingHeading.or(startDmButton)).toBeVisible({ + timeout: 20000, + }) +} + +async function assertRoomAbsentFromSidebar(page, roomId) { + await expect(roomButtonById(page, roomId)).toHaveCount(0, { + timeout: 20000, + }) +} + +When('I open the side seeded test room for leave', async function () { + const roomId = requireEnv('E2E_SIDE_TEST_ROOM_ID') + const roomName = + process.env.E2E_SIDE_TEST_ROOM_NAME || 'Decentra E2E Side Room' + const roomButton = roomButtonById(this.page, roomId) + .or(this.page.getByRole('button', { name: new RegExp(roomName, 'i') })) + .first() + await expect(roomButton).toBeVisible({ timeout: 60000 }) + await roomButton.click() +}) + +When('I select Home in the space rail', async function () { + const homeButton = this.page + .locator('button[data-space-id="__home__"]') + .first() + await expect(homeButton).toBeVisible({ timeout: 20000 }) + await homeButton.click() +}) + +When('I select the seeded test space in the space rail', async function () { + const spaceId = requireEnv('E2E_TEST_SPACE_ID') + const spaceButton = this.page + .locator(`button[data-space-id="${spaceId}"]`) + .first() + await expect(spaceButton).toBeVisible({ timeout: 20000 }) + await spaceButton.click() +}) + +When('I open the seeded test space channel', async function () { + const channelName = + process.env.E2E_TEST_SPACE_CHANNEL_NAME || 'E2E Space General' + const roomButton = this.page + .getByRole('button', { name: new RegExp(channelName, 'i') }) + .first() + await expect(roomButton).toBeVisible({ timeout: 20000 }) + await roomButton.click() +}) + +When('I open the leave test dm room', async function () { + const roomId = leaveDmRoomId() + const peerName = requireEnv('E2E_SECOND_MATRIX_USERNAME') + .split(':')[0] + .replace('@', '') + const roomButton = roomButtonById(this.page, roomId) + .or(this.page.getByRole('button', { name: new RegExp(peerName, 'i') })) + .first() + await expect(roomButton).toBeVisible({ timeout: 60000 }) + await roomButton.click() +}) + +When( + 'I open the channel options menu for the side seeded test room', + async function () { + await openChannelOptionsMenu(this.page, sideTestRoomId()) + }, +) + +When( + 'I open the channel options menu for the seeded test space channel', + async function () { + await openChannelOptionsMenu(this.page, seededSpaceChannelId()) + }, +) + +When( + 'I open the channel options menu for the leave test dm room', + async function () { + await openChannelOptionsMenu(this.page, leaveDmRoomId()) + }, +) + +When('I choose leave channel from the menu', async function () { + await chooseLeaveChannelFromMenu(this.page) +}) + +When('I confirm leaving the channel', async function () { + await confirmLeaveChannel(this.page) +}) + +Then('I should see the chat home onboarding panel', async function () { + await assertChatHomeOnboarding(this.page) +}) + +Then( + 'the side seeded test room should not appear in the sidebar', + async function () { + await assertRoomAbsentFromSidebar(this.page, sideTestRoomId()) + }, +) + +Then( + 'the seeded test space channel should not appear in the sidebar', + async function () { + await assertRoomAbsentFromSidebar(this.page, seededSpaceChannelId()) + }, +) + +Then( + 'the leave test dm room should not appear in the sidebar', + async function () { + await assertRoomAbsentFromSidebar(this.page, leaveDmRoomId()) + }, +) + +Then('I should not be able to send messages in chat', async function () { + await expect(this.page.getByTestId('composer-send-button')) + .not.toBeVisible({ timeout: 10000 }) +}) diff --git a/tests/e2e/support/hooks.mjs b/tests/e2e/support/hooks.mjs index f02054d..90563e0 100644 --- a/tests/e2e/support/hooks.mjs +++ b/tests/e2e/support/hooks.mjs @@ -1,21 +1,38 @@ -import { Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber' +import { Before, After, BeforeAll, AfterAll, setDefaultTimeout } from '@cucumber/cucumber' import { chromium } from '@playwright/test' import { resolve } from 'node:path' import { loadE2EEnv } from '../scripts/runtime-e2e-env.mjs' +setDefaultTimeout(120 * 1000) + let browser let page const isHeadless = process.env.HEADLESS !== 'false' loadE2EEnv(resolve(process.cwd())) +const SYNAPSE_E2E_STEP_MARKERS = [ + 'I open the seeded test room', + 'I open the side seeded test room', + 'leave test dm room', + 'seeded test space channel', + 'seeded test space in the space rail', + 'main test room', + 'seeded space channel', + 'typing in the main test room', +] + function shouldValidateSynapseEnv(pickle) { if (!pickle?.steps) { return false } return pickle.steps.some((step) => { - return typeof step.text === 'string' && - step.text.includes('I open the seeded test room') + if (typeof step.text !== 'string') { + return false + } + return SYNAPSE_E2E_STEP_MARKERS.some((marker) => + step.text.includes(marker), + ) }) }