Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions apps/frontend/e2e/account-deletion.test.ts
Original file line number Diff line number Diff line change
@@ -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<Locator> {
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 }) => {
Expand Down Expand Up @@ -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
);
});
});
});
20 changes: 8 additions & 12 deletions apps/frontend/src/lib/RoomList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`.
Expand All @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions apps/frontend/src/lib/RoomList.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down
18 changes: 8 additions & 10 deletions apps/frontend/src/lib/components/CurrentUserBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`;
});
Expand Down Expand Up @@ -307,11 +309,7 @@ to the user settings page for the active server.
data-testid="current-user-presence-menu"
onclick={openStatusMenu}
>
<UserAvatar
user={activeServerUser}
size="sm"
showPresence
/>
<UserAvatar user={activeServerUser} size="sm" showPresence />
</button>
<div
class="flex min-w-0 flex-1 flex-col overflow-hidden leading-tight"
Expand Down
28 changes: 28 additions & 0 deletions apps/frontend/src/lib/components/CurrentUserBar.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,4 +508,32 @@ describe('CurrentUserBar', () => {
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');
});
});
22 changes: 9 additions & 13 deletions apps/frontend/src/lib/components/QuickSwitcher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
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,
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';
import {
createMemberDirectoryAPI,
type DirectoryMember
} from '$lib/api-client/memberDirectory';
import { createMemberDirectoryAPI, type DirectoryMember } from '$lib/api-client/memberDirectory';
import {
createRoomDirectoryAPI,
RoomDirectoryScope,
Expand Down Expand Up @@ -106,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,
Expand Down Expand Up @@ -230,12 +233,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 ---
Expand Down Expand Up @@ -468,9 +466,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 {
Expand Down
14 changes: 14 additions & 0 deletions apps/frontend/src/lib/components/QuickSwitcher.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,20 @@ describe('QuickSwitcher', () => {
expect(input(container)).toBe(document.activeElement);
});

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,
hasMore: false
}));

const { container } = await renderOpenSwitcher();

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 () => {
const { container } = await renderOpenSwitcher();
const initialCount = resultButtons(container).length;
Expand Down
Loading