diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4bf8a0221..4e579cee6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -137,6 +137,10 @@ export function AppShell() { () => channels.filter((channel) => channel.isMember), [channels], ); + const sidebarChannels = React.useMemo( + () => memberChannels.filter((channel) => channel.archivedAt === null), + [memberChannels], + ); const activeChannel = React.useMemo( () => selectedChannelId @@ -379,7 +383,7 @@ export function AppShell() { , + channelId: string, + archivedAt: string | null, +) { + queryClient.setQueryData(channelsQueryKey, (current = []) => + sortChannels( + current.map((channel) => + channel.id === channelId ? { ...channel, archivedAt } : channel, + ), + ), + ); + + queryClient.setQueryData( + channelDetailQueryKey(channelId), + (current) => (current ? { ...current, archivedAt } : current), + ); +} + export function useChannelsQuery() { return useQuery({ queryKey: channelsQueryKey, @@ -273,6 +292,13 @@ export function useArchiveChannelMutation(channelId: string | null) { await archiveChannel(channelId); }, + onSuccess: () => { + if (!channelId) { + return; + } + + setChannelArchivedState(queryClient, channelId, new Date().toISOString()); + }, onSettled: async () => { await invalidateChannelState(queryClient, channelId); }, @@ -290,6 +316,13 @@ export function useUnarchiveChannelMutation(channelId: string | null) { await unarchiveChannel(channelId); }, + onSuccess: () => { + if (!channelId) { + return; + } + + setChannelArchivedState(queryClient, channelId, null); + }, onSettled: async () => { await invalidateChannelState(queryClient, channelId); }, diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 10f99063e..a9e261c3c 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -108,8 +108,9 @@ export function ChannelBrowserDialog({ const filtered = channels.filter( (channel) => channel.channelType !== "dm" && - channel.visibility === "open" && - !channel.archivedAt && + (channel.archivedAt + ? channel.isMember + : channel.visibility === "open") && (channelTypeFilter ? channel.channelType === channelTypeFilter : true), ); @@ -133,6 +134,10 @@ export function ChannelBrowserDialog({ () => browsableChannels.filter((channel) => channel.isMember), [browsableChannels], ); + const hasArchivedJoinedChannels = React.useMemo( + () => joined.some((channel) => channel.archivedAt !== null), + [joined], + ); // Flat list for keyboard navigation: not-joined first, then joined const allItems = React.useMemo( @@ -324,7 +329,9 @@ export function ChannelBrowserDialog({
- Showing open {entityLabel}s. Private {entityLabel}s require an invite. + {hasArchivedJoinedChannels + ? `Showing open ${entityLabel}s and your archived ${entityLabel}s. Private ${entityLabel}s require an invite.` + : `Showing open ${entityLabel}s. Private ${entityLabel}s require an invite.`}
@@ -375,6 +382,11 @@ function ChannelCard({

{channel.channelType}

+ {channel.archivedAt ? ( +

+ archived +

+ ) : null}
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 728187f9b..3227201c9 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -571,15 +571,26 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => { await expect(page.getByTestId("channel-management-unarchive")).toBeVisible(); await closeChannelManagement(page); + await expect(page.getByTestId("stream-list")).not.toContainText("general"); await expect(page.getByTestId("message-input")).toBeDisabled(); await expect(page.getByTestId("send-message")).toBeDisabled(); + await page.getByTestId("browse-channels").click(); + await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); + await expect(page.getByTestId("browse-channel-general")).toContainText( + "archived", + ); + await page.getByTestId("browse-channel-general").click(); + await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("channel-management-trigger").click(); await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); await page.getByTestId("channel-management-unarchive").click(); await expect(page.getByTestId("channel-management-archive")).toBeVisible(); await closeChannelManagement(page); + await expect(page.getByTestId("stream-list")).toContainText("general"); await expect(page.getByTestId("message-input")).toBeEnabled(); }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 2956e8a8e..2c6a88434 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -542,12 +542,20 @@ test("manage sheet archive and unarchive survives a reload through the relay", a await expect(page.getByTestId("channel-management-unarchive")).toBeVisible(); await closeChannelManagement(page); + await expect(page.getByTestId("stream-list")).not.toContainText(channelName); await expect(page.getByTestId("message-input")).toBeDisabled(); await expect(page.getByTestId("send-message")).toBeDisabled(); await page.reload(); - await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("stream-list")).not.toContainText(channelName); + await page.getByTestId("browse-channels").click(); + await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); + await expect(page.getByTestId(`browse-channel-${channelName}`)).toContainText( + "archived", + ); + await page.getByTestId(`browse-channel-${channelName}`).click(); + await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible(); await expect(page.getByTestId("chat-title")).toHaveText(channelName); await expect(page.getByTestId("message-input")).toBeDisabled(); @@ -556,5 +564,6 @@ test("manage sheet archive and unarchive survives a reload through the relay", a await expect(page.getByTestId("channel-management-archive")).toBeVisible(); await closeChannelManagement(page); + await expect(page.getByTestId("stream-list")).toContainText(channelName); await expect(page.getByTestId("message-input")).toBeEnabled(); }); diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index f386406fb..f149b7a1c 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -3,6 +3,15 @@ import { expect, test, type Browser, type Page } from "@playwright/test"; import { installRelayBridge } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; +const isCi = Boolean(process.env.CI); +const relayDeliveryTimeoutMs = isCi ? 15_000 : 5_000; + +async function expectTimelineToContain(page: Page, text: string) { + await expect(page.getByTestId("message-timeline")).toContainText(text, { + timeout: relayDeliveryTimeoutMs, + }); +} + async function getTimelineMetrics(page: Page) { return page.getByTestId("message-timeline").evaluate((element) => { const timeline = element as HTMLDivElement; @@ -36,9 +45,7 @@ async function ensureTimelineScrollable( await expect(input).toBeEnabled(); await input.fill(message); await sendButton.click(); - await expect(receiverPage.getByTestId("message-timeline")).toContainText( - message, - ); + await expectTimelineToContain(receiverPage, message); } const metrics = await getTimelineMetrics(receiverPage); @@ -143,7 +150,7 @@ test("sends a message through the real relay", async ({ page }) => { await page.getByTestId("message-input").fill(message); await page.getByTestId("send-message").click(); - await expect(page.getByTestId("message-timeline")).toContainText(message); + await expectTimelineToContain(page, message); }); test("delivers a message to a second browser context in real time", async ({ @@ -169,9 +176,7 @@ test("delivers a message to a second browser context in real time", async ({ await pageOne.getByTestId("message-input").fill(message); await pageOne.getByTestId("send-message").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - message, - ); + await expectTimelineToContain(pageTwo, message); } finally { await contextOne.close(); await contextTwo.close(); @@ -183,6 +188,8 @@ test("stays pinned to the latest message when new messages arrive at the bottom" }: { browser: Browser; }) => { + test.slow(); + const channelName = `pinned-bottom-${Date.now()}`; const contextOne = await browser.newContext(); const contextTwo = await browser.newContext(); @@ -207,9 +214,7 @@ test("stays pinned to the latest message when new messages arrive at the bottom" await pageOne.getByTestId("message-input").fill(incomingMessage); await pageOne.getByTestId("send-message").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - incomingMessage, - ); + await expectTimelineToContain(pageTwo, incomingMessage); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) .toBeLessThan(8); @@ -227,6 +232,8 @@ test("stays pinned after you send a message and a remote reply arrives right aft }: { browser: Browser; }) => { + test.slow(); + const channelName = `reply-shared-${Date.now()}`; const contextOne = await browser.newContext(); const contextTwo = await browser.newContext(); @@ -251,16 +258,12 @@ test("stays pinned after you send a message and a remote reply arrives right aft await pageTwo.getByTestId("message-input").fill(localMessage); await pageTwo.getByTestId("send-message").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - localMessage, - ); + await expectTimelineToContain(pageTwo, localMessage); await pageOne.getByTestId("message-input").fill(incomingMessage); await pageOne.getByTestId("send-message").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - incomingMessage, - ); + await expectTimelineToContain(pageTwo, incomingMessage); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) .toBeLessThan(8); @@ -278,6 +281,8 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({ }: { browser: Browser; }) => { + test.slow(); + const channelName = `composer-shared-${Date.now()}`; const contextOne = await browser.newContext(); const contextTwo = await browser.newContext(); @@ -315,9 +320,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({ await pageOne.getByTestId("message-input").fill(incomingMessage); await pageOne.getByTestId("send-message").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - incomingMessage, - ); + await expectTimelineToContain(pageTwo, incomingMessage); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) .toBeLessThan(8); @@ -335,6 +338,8 @@ test("keeps scroll position when new messages arrive above the fold", async ({ }: { browser: Browser; }) => { + test.slow(); + const channelName = `scroll-shared-${Date.now()}`; const contextOne = await browser.newContext(); const contextTwo = await browser.newContext(); @@ -370,9 +375,7 @@ test("keeps scroll position when new messages arrive above the fold", async ({ await pageTwo.getByTestId("message-scroll-to-latest").click(); - await expect(pageTwo.getByTestId("message-timeline")).toContainText( - incomingMessage, - ); + await expectTimelineToContain(pageTwo, incomingMessage); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) .toBeLessThan(8); diff --git a/desktop/tests/helpers/seed.ts b/desktop/tests/helpers/seed.ts index f5a2daa3a..e46456338 100644 --- a/desktop/tests/helpers/seed.ts +++ b/desktop/tests/helpers/seed.ts @@ -2,14 +2,15 @@ import { request } from "@playwright/test"; const tylerPubkey = "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"; +const isCi = Boolean(process.env.CI); const relayBaseUrl = - process.env.SPROUT_E2E_RELAY_URL ?? "http://localhost:3000"; + process.env.SPROUT_E2E_RELAY_URL ?? "http://127.0.0.1:3000"; const seedTimeoutMs = Number.parseInt( - process.env.SPROUT_E2E_SEED_TIMEOUT_MS ?? "25000", + process.env.SPROUT_E2E_SEED_TIMEOUT_MS ?? (isCi ? "60000" : "25000"), 10, ); const requestTimeoutMs = Number.parseInt( - process.env.SPROUT_E2E_SEED_REQUEST_TIMEOUT_MS ?? "2000", + process.env.SPROUT_E2E_SEED_REQUEST_TIMEOUT_MS ?? (isCi ? "5000" : "2000"), 10, ); const retryDelayMs = Number.parseInt(