From 8fcb59999145edd98009bd273efd2ff73e1b9bfc Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Wed, 8 Jul 2026 15:30:08 +0200 Subject: [PATCH 1/2] Centralize DM display labels and deleted-user handling --- apps/frontend/src/lib/RoomList.svelte | 20 +++--- apps/frontend/src/lib/RoomList.svelte.spec.ts | 55 +++++++++++++++ .../src/lib/components/CurrentUserBar.svelte | 18 +++-- .../components/CurrentUserBar.svelte.spec.ts | 28 ++++++++ .../src/lib/components/QuickSwitcher.svelte | 17 ++--- .../components/QuickSwitcher.svelte.spec.ts | 13 ++++ apps/frontend/src/lib/dm/display.spec.ts | 68 +++++++++++++++++++ apps/frontend/src/lib/dm/display.ts | 51 ++++++++++++++ .../chat/[serverId]/[roomId]/Room.svelte | 14 ++-- .../[serverId]/[roomId]/Room.svelte.spec.ts | 44 +++++++++++- .../RoomLocalEchoPaneHeaderMock.svelte | 15 ++++ 11 files changed, 299 insertions(+), 44 deletions(-) create mode 100644 apps/frontend/src/lib/dm/display.spec.ts create mode 100644 apps/frontend/src/lib/dm/display.ts create mode 100644 apps/frontend/src/routes/chat/[serverId]/[roomId]/RoomLocalEchoPaneHeaderMock.svelte diff --git a/apps/frontend/src/lib/RoomList.svelte b/apps/frontend/src/lib/RoomList.svelte index 0246b2f9c..d8e9ff7e2 100644 --- a/apps/frontend/src/lib/RoomList.svelte +++ b/apps/frontend/src/lib/RoomList.svelte @@ -34,6 +34,7 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet import { prepareUiForNotificationTarget } from '$lib/notifications/notificationNavigationUi'; import { getAppUiState } from '$lib/state/appUi.svelte'; import { appState } from '$lib/state/globals.svelte'; + import { getDMAvatarParticipants, getDMConversationLabel } from '$lib/dm/display'; import { getLiveDisplayName } from '$lib/state/userProfiles.svelte'; import type { EventEnvelope } from '$lib/eventBus.svelte'; import { isMessagePostedEvent, RoomEventKind, roomEventKind } from '$lib/render/eventKinds'; @@ -131,8 +132,9 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet // When no layout exists, display channels alphabetically let sortedRooms = $derived([...channels].sort((a, b) => a.name.localeCompare(b.name))); - // DM display name: comma-joined participants other than the current user - // (or "You" for self-DMs). + // DM display name: comma-joined participants other than the current user. + // If the endpoint only returns the current user, the remote account was + // deleted and the conversation should be labeled as a deleted-user DM. // // `meId` comes from `roomsStore.currentUserId`, which is captured from the // same `viewer { user { id, rooms { members } } }` query that produced `room.members`. @@ -143,19 +145,13 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet // label/avatars (e.g. a 1:1 with Teal rendering as "Teal, hmans"). function dmDisplayName(room: RoomsListItem): string { const meId = roomsStore.currentUserId; - const others = room.members.filter((m) => m.id !== meId); - if (others.length === 0) return m['common.you'](); - return others.map((m) => getLiveDisplayName(m.id, m.displayName || m.login)).join(', '); + return getDMConversationLabel(room.members, meId, m['room.sidebar.deleted_user'](), (member) => + getLiveDisplayName(member.id, member.displayName || member.login) + ); } function dmAvatarParticipants(room: RoomsListItem) { - const meId = roomsStore.currentUserId; - const others = room.members.filter((m) => m.id !== meId); - if (others.length === 0) { - // Self-DM: show own avatar - return room.members.slice(0, 1); - } - return others.slice(0, 3); + return getDMAvatarParticipants(room.members, roomsStore.currentUserId, 3); } function callParticipantAvatarUser(participant: CallRoomParticipant): UserAvatarUserView { diff --git a/apps/frontend/src/lib/RoomList.svelte.spec.ts b/apps/frontend/src/lib/RoomList.svelte.spec.ts index 2f0268ba6..a42fa1b75 100644 --- a/apps/frontend/src/lib/RoomList.svelte.spec.ts +++ b/apps/frontend/src/lib/RoomList.svelte.spec.ts @@ -295,6 +295,61 @@ beforeEach(() => { }); describe('RoomList', () => { + it('renders normal 1:1 DM rows with the other participant name', async () => { + const { container } = render(RoomList); + + const dmRow = q(container, '[href="/chat/-/dm-with-participants"]'); + await expect.element(dmRow).toBeInTheDocument(); + expect(dmRow?.textContent ?? '').toContain('Teal'); + expect(dmRow?.textContent ?? '').not.toContain('Me'); + }); + + it('renders current-user-only DM rows as deleted-user conversations', async () => { + (mocks.store.rooms.rooms as unknown[]).push({ + id: 'dm-deleted-peer', + name: '', + type: RoomType.Dm, + isUniversal: false, + hasUnread: false, + viewerIsMember: true, + viewerCanJoinRoom: true, + viewerNotificationCount: 0, + members: [user('me', 'me', 'Me')] + }); + + const { container } = render(RoomList); + + const dmRow = q(container, '[href="/chat/-/dm-deleted-peer"]'); + await expect.element(dmRow).toBeInTheDocument(); + expect(dmRow?.textContent ?? '').toContain('Deleted User'); + expect(dmRow?.textContent ?? '').not.toContain('Me'); + }); + + it('renders group DM rows with non-current participants', async () => { + (mocks.store.rooms.rooms as unknown[]).push({ + id: 'dm-group', + name: '', + type: RoomType.Dm, + isUniversal: false, + hasUnread: false, + viewerIsMember: true, + viewerCanJoinRoom: true, + viewerNotificationCount: 0, + members: [ + user('me', 'me', 'Me'), + user('teal', 'teal', 'Teal'), + user('river', 'river', 'River') + ] + }); + + const { container } = render(RoomList); + + const dmRow = q(container, '[href="/chat/-/dm-group"]'); + await expect.element(dmRow).toBeInTheDocument(); + expect(dmRow?.textContent ?? '').toContain('Teal, River'); + expect(dmRow?.textContent ?? '').not.toContain('Me'); + }); + it('renders active-call DM rows with the pulse icon and participant avatars', async () => { mocks.activeCallRoomIds.add('dm-with-participants'); mocks.callParticipants.set('dm-with-participants', [ diff --git a/apps/frontend/src/lib/components/CurrentUserBar.svelte b/apps/frontend/src/lib/components/CurrentUserBar.svelte index 82e4ccb43..c2b51eb31 100644 --- a/apps/frontend/src/lib/components/CurrentUserBar.svelte +++ b/apps/frontend/src/lib/components/CurrentUserBar.svelte @@ -13,6 +13,7 @@ to the user settings page for the active server. import { getActiveServer } from '$lib/state/activeServer.svelte'; import { serverRegistry } from '$lib/state/server/registry.svelte'; import { useConnection } from '$lib/state/server/connection.svelte'; + import { getDMConversationLabel } from '$lib/dm/display'; import { getLiveDisplayName, type CustomUserStatus } from '$lib/state/userProfiles.svelte'; import { setPresenceMode } from '$lib/presenceTracking'; import { presencePreference, type PresenceMode } from '$lib/state/presencePreference.svelte'; @@ -64,11 +65,12 @@ to the user settings page for the active server. if (!room) return m['common.current_call'](); if (room.type === RoomType.Dm) { const meId = roomsStore?.currentUserId; - const others = room.members.filter((member) => member.id !== meId); - if (others.length === 0) return m['common.you'](); - return others - .map((member) => getLiveDisplayName(member.id, member.displayName || member.login)) - .join(', '); + return getDMConversationLabel( + room.members, + meId, + m['room.sidebar.deleted_user'](), + (member) => getLiveDisplayName(member.id, member.displayName || member.login) + ); } return `# ${room.name}`; }); @@ -307,11 +309,7 @@ to the user settings page for the active server. data-testid="current-user-presence-menu" onclick={openStatusMenu} > - +
{ expect(callLink).toBeTruthy(); expect(callLink!.textContent ?? '').toContain('Bob'); }); + + it('uses the deleted-user label for active direct-message calls that only list the current user', () => { + voiceCallState.connected = true; + voiceCallState.roomId = 'dm-1'; + roomsState.rooms = [ + { + id: 'dm-1', + name: 'dm-1', + type: 'DM', + members: [ + { + id: 'user-1', + login: 'alice', + displayName: 'Alice', + avatarUrl: null, + presenceStatus: PresenceStatus.Online + } + ] + } + ]; + + const { container } = render(CurrentUserBarTestHarness); + + const callLink = q(container, '[data-testid="current-user-call-link"]'); + expect(callLink).toBeTruthy(); + expect(callLink!.textContent ?? '').toContain('Deleted User'); + expect(callLink!.textContent ?? '').not.toContain('Alice'); + }); }); diff --git a/apps/frontend/src/lib/components/QuickSwitcher.svelte b/apps/frontend/src/lib/components/QuickSwitcher.svelte index f05e09856..3fc2df932 100644 --- a/apps/frontend/src/lib/components/QuickSwitcher.svelte +++ b/apps/frontend/src/lib/components/QuickSwitcher.svelte @@ -12,13 +12,11 @@ import { recentQuickSwitcher } from '$lib/state/recentQuickSwitcher.svelte'; import { quickSwitcher } from '$lib/state/globals.svelte'; import { ROOM_MEMBERS_PAGE_SIZE } from '$lib/state/room/members.svelte'; + import { getDMAvatarParticipants, getDMConversationLabel } from '$lib/dm/display'; import * as m from '$lib/i18n/messages'; import { toast } from '$lib/ui/toast'; import { createRoomCommandAPI } from '$lib/api-client/rooms'; - import { - createMemberDirectoryAPI, - type DirectoryMember - } from '$lib/api-client/memberDirectory'; + import { createMemberDirectoryAPI, type DirectoryMember } from '$lib/api-client/memberDirectory'; import { createRoomDirectoryAPI, RoomDirectoryScope, @@ -230,12 +228,7 @@ } function dmLabel(participants: AvatarUser[], currentUserId: string | undefined): string { - const others = participants.filter((p) => p.id !== currentUserId); - if (others.length === 0) { - const self = participants.find((p) => p.id === currentUserId); - return self ? self.displayName || self.login : 'You'; - } - return others.map((p) => p.displayName || p.login).join(', '); + return getDMConversationLabel(participants, currentUserId, m['room.sidebar.deleted_user']()); } // --- Filtering --- @@ -468,9 +461,7 @@ } function dmAvatarParticipants(item: ResultItem): AvatarUser[] { - if (!item.participants) return []; - const others = item.participants.filter((p) => p.id !== item.currentUserId); - return others.length === 0 ? item.participants.slice(0, 1) : others.slice(0, 2); + return getDMAvatarParticipants(item.participants, item.currentUserId, 2); } function userAvatarParticipant(item: ResultItem): AvatarUser | null { diff --git a/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts b/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts index 36e26cc55..3f8364b31 100644 --- a/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts +++ b/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts @@ -289,6 +289,19 @@ describe('QuickSwitcher', () => { expect(input(container)).toBe(document.activeElement); }); + it('shows deleted-user labels for DMs whose only listed member is the current user', async () => { + mocks.listRoomMembers.mockImplementation(async (roomId: string) => ({ + members: roomId === 'dm-existing' ? [currentUser] : [], + totalCount: roomId === 'dm-existing' ? 1 : 0, + hasMore: false + })); + + const { container } = await renderOpenSwitcher(); + + expect(container.textContent).toContain('Deleted User'); + expect(container.textContent).not.toContain('Alice Current'); + }); + it('fuzzy-filters rooms and shows no results for misses', async () => { const { container } = await renderOpenSwitcher(); const initialCount = resultButtons(container).length; diff --git a/apps/frontend/src/lib/dm/display.spec.ts b/apps/frontend/src/lib/dm/display.spec.ts new file mode 100644 index 000000000..5c05c375e --- /dev/null +++ b/apps/frontend/src/lib/dm/display.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + getDMAvatarParticipants, + getDMConversationLabel, + type DMDisplayParticipant +} from './display'; + +const deletedUserLabel = 'Deleted User'; + +function participant(id: string, displayName: string, deleted = false): DMDisplayParticipant { + return { + id, + login: displayName.toLowerCase().replace(/\s+/g, '-'), + displayName, + deleted + }; +} + +describe('DM display helpers', () => { + it('uses the other participant for a normal 1:1 DM', () => { + expect( + getDMConversationLabel( + [participant('current', 'Current User'), participant('peer', 'Peer User')], + 'current', + deletedUserLabel + ) + ).toBe('Peer User'); + }); + + it('uses the deleted-user label when the only listed DM member is the current user', () => { + expect( + getDMConversationLabel([participant('current', 'Current User')], 'current', deletedUserLabel) + ).toBe(deletedUserLabel); + }); + + it('joins non-current participants for group DMs', () => { + expect( + getDMConversationLabel( + [ + participant('current', 'Current User'), + participant('peer-a', 'Peer A'), + participant('peer-b', 'Peer B') + ], + 'current', + deletedUserLabel + ) + ).toBe('Peer A, Peer B'); + }); + + it('keeps deleted non-current participants labeled as deleted users', () => { + expect( + getDMConversationLabel( + [participant('current', 'Current User'), participant('peer', 'Former Peer', true)], + 'current', + deletedUserLabel + ) + ).toBe(deletedUserLabel); + }); + + it('keeps avatar participants aligned with the visible DM label participants', () => { + const current = participant('current', 'Current User'); + const peerA = participant('peer-a', 'Peer A'); + const peerB = participant('peer-b', 'Peer B'); + + expect(getDMAvatarParticipants([current, peerA, peerB], 'current', 1)).toEqual([peerA]); + expect(getDMAvatarParticipants([current], 'current', 1)).toEqual([current]); + }); +}); diff --git a/apps/frontend/src/lib/dm/display.ts b/apps/frontend/src/lib/dm/display.ts new file mode 100644 index 000000000..92abce67c --- /dev/null +++ b/apps/frontend/src/lib/dm/display.ts @@ -0,0 +1,51 @@ +export type DMDisplayParticipant = { + id: string; + login: string; + displayName: string; + deleted?: boolean | null; +}; + +type ParticipantFormatter = (participant: T) => string; + +function defaultParticipantLabel(participant: DMDisplayParticipant): string { + return participant.displayName || participant.login; +} + +/** + * Derive the visible label for a DM conversation from the members returned by + * the room member endpoint. + */ +export function getDMConversationLabel( + participants: readonly T[], + currentUserId: string | null | undefined, + deletedUserLabel: string, + formatParticipant: ParticipantFormatter = defaultParticipantLabel +): string { + const otherParticipants = participants.filter((participant) => participant.id !== currentUserId); + + if (otherParticipants.length === 0) { + return deletedUserLabel; + } + + return otherParticipants + .map((participant) => { + if (participant.deleted) return deletedUserLabel; + return formatParticipant(participant); + }) + .join(', '); +} + +export function getDMAvatarParticipants( + participants: readonly T[] | null | undefined, + currentUserId: string | null | undefined, + limit: number +): T[] { + const availableParticipants = participants ?? []; + const otherParticipants = availableParticipants.filter( + (participant) => participant.id !== currentUserId + ); + const visibleParticipants = + otherParticipants.length === 0 ? availableParticipants : otherParticipants; + + return visibleParticipants.slice(0, limit); +} diff --git a/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte b/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte index 0ecf038e7..d2d4a2247 100644 --- a/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte +++ b/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte @@ -33,6 +33,7 @@ import { getAppUiState } from '$lib/state/appUi.svelte'; import { useConnection } from '$lib/state/server/connection.svelte'; import { serverRegistry } from '$lib/state/server/registry.svelte'; + import { getDMConversationLabel } from '$lib/dm/display'; import { getLiveDisplayName } from '$lib/state/userProfiles.svelte'; import { resolve } from '$app/paths'; import { serverIdToSegment } from '$lib/navigation'; @@ -218,12 +219,13 @@ return m['room.title.direct_message'](); } - const others = room.dmData.participants.filter((p) => p.id !== room.dmData!.currentUserId); - if (others.length === 0) { - const self = room.dmData.participants.find((p) => p.id === room.dmData!.currentUserId); - return self?.displayName || self?.login || m['common.you'](); - } - return others.map((p) => getLiveDisplayName(p.id, p.displayName || p.login)).join(', '); + return getDMConversationLabel( + room.dmData.participants, + room.dmData.currentUserId, + m['room.sidebar.deleted_user'](), + (participant) => + getLiveDisplayName(participant.id, participant.displayName || participant.login) + ); }); let roomDescription = $derived.by(() => { diff --git a/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte.spec.ts b/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte.spec.ts index 8b8486ece..e9186182a 100644 --- a/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte.spec.ts +++ b/apps/frontend/src/routes/chat/[serverId]/[roomId]/Room.svelte.spec.ts @@ -51,6 +51,17 @@ const { mocks } = vi.hoisted(() => { }, livekitUrl: null as string | null, roomKind: 1, + dmData: null as null | { + participants: Array<{ + id: string; + login: string; + displayName: string; + deleted?: boolean; + avatarUrl: string | null; + presenceStatus: string; + }>; + currentUserId: string | null; + }, sidebarNav: { isMobile: false }, @@ -119,7 +130,7 @@ vi.mock('$lib/hooks', () => ({ canManageRoom: false, canBanRoomMembers: false }, - dmData: null, + dmData: mocks.dmData, isDM: mocks.roomKind === RoomKind.DM, isRoomLoading: false }), @@ -207,6 +218,10 @@ vi.mock('$lib/state/appUi.svelte', async (importActual) => { }; }); +vi.mock('$lib/state/userProfiles.svelte', () => ({ + getLiveDisplayName: (_userId: string, fallback: string) => fallback +})); + vi.mock('$lib/storage/lastRoom', () => ({ clearLastRoom: vi.fn(), setLastRoom: vi.fn() @@ -263,8 +278,8 @@ vi.mock('$lib/ui/PageTitle.svelte', async () => { }); vi.mock('$lib/ui/PaneHeader.svelte', async () => { - const { default: EmptyMock } = await import('./RoomLocalEchoEmptyMock.svelte'); - return { default: EmptyMock }; + const { default: PaneHeaderMock } = await import('./RoomLocalEchoPaneHeaderMock.svelte'); + return { default: PaneHeaderMock }; }); import Room from './Room.svelte'; @@ -293,6 +308,7 @@ beforeEach(() => { mocks.timeline.getThreadEventsAround.mockResolvedValue(emptyTimelinePage()); mocks.livekitUrl = null; mocks.roomKind = RoomKind.CHANNEL; + mocks.dmData = null; mocks.sidebarNav.isMobile = false; appUi = new AppUiState(); appUi.setActiveRoomScope('server-1', 'room-1'); @@ -325,6 +341,28 @@ describe('Room local message echo', () => { expect(mocks.resetTypingDebounce).toHaveBeenCalledOnce(); }); + it('uses the deleted-user label for a direct-message room whose only participant is the current user', async () => { + mocks.roomKind = RoomKind.DM; + mocks.dmData = { + participants: [ + { + id: 'test-user', + login: 'testuser', + displayName: 'Test User', + deleted: false, + avatarUrl: null, + presenceStatus: 'ONLINE' + } + ], + currentUserId: 'test-user' + }; + + const { container } = render(Room, { props: { roomId: 'room-1' } }); + + await expect.element(q(container, 'h1')).toHaveTextContent('Deleted User'); + expect(q(container, 'h1')?.textContent ?? '').not.toContain('Test User'); + }); + it('does not advance the current room read cursor for a stale returned post from another room', async () => { const { container } = render(Room, { props: { roomId: 'room-2' } }); diff --git a/apps/frontend/src/routes/chat/[serverId]/[roomId]/RoomLocalEchoPaneHeaderMock.svelte b/apps/frontend/src/routes/chat/[serverId]/[roomId]/RoomLocalEchoPaneHeaderMock.svelte new file mode 100644 index 000000000..3dbc01c07 --- /dev/null +++ b/apps/frontend/src/routes/chat/[serverId]/[roomId]/RoomLocalEchoPaneHeaderMock.svelte @@ -0,0 +1,15 @@ + + +
+ {#if !loading} +

{title}

+ {/if} +
From 32762c01e94d8cb4540362f3a0877f05d47a2c22 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Wed, 8 Jul 2026 18:10:09 +0200 Subject: [PATCH 2/2] Hide deleted-user DMs from quick switcher --- apps/frontend/e2e/account-deletion.test.ts | 85 ++++++++++++++++++- .../src/lib/components/QuickSwitcher.svelte | 7 +- .../components/QuickSwitcher.svelte.spec.ts | 5 +- apps/frontend/src/lib/dm/display.spec.ts | 19 +++++ apps/frontend/src/lib/dm/display.ts | 9 ++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/frontend/e2e/account-deletion.test.ts b/apps/frontend/e2e/account-deletion.test.ts index bd9c51a2a..2bcc37eea 100644 --- a/apps/frontend/e2e/account-deletion.test.ts +++ b/apps/frontend/e2e/account-deletion.test.ts @@ -1,13 +1,29 @@ -import { expect } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; import { createAndLoginTestUser } from './fixtures/testUser'; import { withServerUser } from './fixtures/serverUser'; import { waitForRoomReady } from './fixtures/realtimeSync'; import { waitForUserDeletedViaConnect } from './fixtures/connectHelpers'; import { test } from './setup'; -import { AccountPage } from './pages'; +import { AccountPage, DMPage } from './pages'; import { TIMEOUTS } from './constants'; import * as routes from './routes'; +async function openQuickSwitcher(page: Page): Promise { + const dialog = page.locator('dialog.quick-switcher'); + const shortcut = process.platform === 'darwin' ? 'Meta+k' : 'Control+k'; + + await expect(async () => { + await page.keyboard.press(shortcut); + await expect(dialog).toBeVisible({ timeout: 500 }); + }).toPass({ timeout: TIMEOUTS.UI_STANDARD, intervals: [200, 500, 1000] }); + + return dialog; +} + +function quickSwitcherResults(dialog: Locator): Locator { + return dialog.locator('button.sidebar-item'); +} + test.describe('Account Deletion', () => { test.describe('Account Settings Page', () => { test('can navigate to account settings page', async ({ page, accountPage }) => { @@ -312,5 +328,70 @@ test.describe('Account Deletion', () => { } ); }); + + test('direct-message rooms with a deleted peer render as Deleted User and stay out of quick switcher', async ({ + page, + chatPage, + browser, + serverURL + }) => { + const userA = await createAndLoginTestUser(page); + await chatPage.goto(); + + let conversationId = ''; + let deletedDisplayName = ''; + + await withServerUser(browser!, serverURL, async ({ page: page2, user: userB }) => { + deletedDisplayName = userB.displayName; + + const roomB = await new DMPage(page2).startConversation(userA.login); + await roomB.sendMessage(`deleted peer dm seed ${Date.now()}`); + conversationId = page2.url().split('/').pop()!; + + await page.goto(routes.room(conversationId)); + await page.waitForURL(routes.patterns.anyRoom); + await waitForRoomReady(page); + + await expect(page.getByRole('heading', { name: userB.displayName })).toBeVisible({ + timeout: TIMEOUTS.REALTIME_EVENT + }); + + const accountPage2 = new AccountPage(page2); + await accountPage2.goto(); + await accountPage2.deleteAccount(); + + await waitForUserDeletedViaConnect(page, userB.id!); + }); + + await page.reload(); + await page.waitForURL(routes.patterns.anyRoom); + await waitForRoomReady(page); + + const deletedConversation = page + .locator('nav a.sidebar-item') + .filter({ has: page.getByText('Deleted User', { exact: true }) }); + await expect(deletedConversation).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); + await expect(deletedConversation).toHaveAttribute( + 'href', + new RegExp(`/chat/-/${conversationId}$`) + ); + await expect(deletedConversation).not.toContainText('You'); + + await expect(page.getByRole('heading', { name: 'Deleted User', exact: true })).toBeVisible({ + timeout: TIMEOUTS.REALTIME_EVENT + }); + await expect( + page.getByRole('heading', { name: deletedDisplayName, exact: true }) + ).not.toBeVisible(); + + const dialog = await openQuickSwitcher(page); + await expect(quickSwitcherResults(dialog).first()).toBeVisible({ + timeout: TIMEOUTS.UI_STANDARD + }); + await expect(quickSwitcherResults(dialog).filter({ hasText: 'Deleted User' })).toHaveCount(0); + await expect(quickSwitcherResults(dialog).filter({ hasText: 'Direct Message' })).toHaveCount( + 0 + ); + }); }); }); diff --git a/apps/frontend/src/lib/components/QuickSwitcher.svelte b/apps/frontend/src/lib/components/QuickSwitcher.svelte index 3fc2df932..c953a5257 100644 --- a/apps/frontend/src/lib/components/QuickSwitcher.svelte +++ b/apps/frontend/src/lib/components/QuickSwitcher.svelte @@ -12,7 +12,11 @@ import { recentQuickSwitcher } from '$lib/state/recentQuickSwitcher.svelte'; import { quickSwitcher } from '$lib/state/globals.svelte'; import { ROOM_MEMBERS_PAGE_SIZE } from '$lib/state/room/members.svelte'; - import { getDMAvatarParticipants, getDMConversationLabel } from '$lib/dm/display'; + import { + getDMAvatarParticipants, + getDMConversationLabel, + hasVisibleDMParticipant + } from '$lib/dm/display'; import * as m from '$lib/i18n/messages'; import { toast } from '$lib/ui/toast'; import { createRoomCommandAPI } from '$lib/api-client/rooms'; @@ -104,6 +108,7 @@ const participants = ( await members.listRoomMembers(room.id, '', ROOM_MEMBERS_PAGE_SIZE, 0) ).members.map(avatarUser); + if (!hasVisibleDMParticipant(participants, currentUserId)) return; items.push({ kind: 'dm', id: room.id, diff --git a/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts b/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts index 3f8364b31..fe4c54452 100644 --- a/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts +++ b/apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts @@ -289,7 +289,7 @@ describe('QuickSwitcher', () => { expect(input(container)).toBe(document.activeElement); }); - it('shows deleted-user labels for DMs whose only listed member is the current user', async () => { + it('omits DMs whose only listed member is the current user', async () => { mocks.listRoomMembers.mockImplementation(async (roomId: string) => ({ members: roomId === 'dm-existing' ? [currentUser] : [], totalCount: roomId === 'dm-existing' ? 1 : 0, @@ -298,8 +298,9 @@ describe('QuickSwitcher', () => { const { container } = await renderOpenSwitcher(); - expect(container.textContent).toContain('Deleted User'); + expect(container.textContent).not.toContain('Deleted User'); expect(container.textContent).not.toContain('Alice Current'); + expect(container.textContent).not.toContain('Direct Message'); }); it('fuzzy-filters rooms and shows no results for misses', async () => { diff --git a/apps/frontend/src/lib/dm/display.spec.ts b/apps/frontend/src/lib/dm/display.spec.ts index 5c05c375e..bd6ea7bb8 100644 --- a/apps/frontend/src/lib/dm/display.spec.ts +++ b/apps/frontend/src/lib/dm/display.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getDMAvatarParticipants, getDMConversationLabel, + hasVisibleDMParticipant, type DMDisplayParticipant } from './display'; @@ -65,4 +66,22 @@ describe('DM display helpers', () => { expect(getDMAvatarParticipants([current, peerA, peerB], 'current', 1)).toEqual([peerA]); expect(getDMAvatarParticipants([current], 'current', 1)).toEqual([current]); }); + + it('detects whether a DM has a visible non-current participant', () => { + expect( + hasVisibleDMParticipant( + [participant('current', 'Current User'), participant('peer', 'Peer User')], + 'current' + ) + ).toBe(true); + expect(hasVisibleDMParticipant([participant('current', 'Current User')], 'current')).toBe( + false + ); + expect( + hasVisibleDMParticipant( + [participant('current', 'Current User'), participant('peer', 'Former Peer', true)], + 'current' + ) + ).toBe(false); + }); }); diff --git a/apps/frontend/src/lib/dm/display.ts b/apps/frontend/src/lib/dm/display.ts index 92abce67c..3312e9718 100644 --- a/apps/frontend/src/lib/dm/display.ts +++ b/apps/frontend/src/lib/dm/display.ts @@ -49,3 +49,12 @@ export function getDMAvatarParticipants( return visibleParticipants.slice(0, limit); } + +export function hasVisibleDMParticipant( + participants: readonly T[], + currentUserId: string | null | undefined +): boolean { + return participants.some( + (participant) => participant.id !== currentUserId && !participant.deleted + ); +}