[codex] hide archived channels from sidebar (#260)

This commit is contained in:
Wes
2026-04-07 14:41:22 -07:00
committed by GitHub
parent e10d4529de
commit ef15e38730
7 changed files with 103 additions and 30 deletions
+5 -1
View File
@@ -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() {
</Button>
</div>
<AppSidebar
channels={memberChannels}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
+33
View File
@@ -86,6 +86,25 @@ async function invalidateChannelState(
]);
}
function setChannelArchivedState(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string,
archivedAt: string | null,
) {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels(
current.map((channel) =>
channel.id === channelId ? { ...channel, archivedAt } : channel,
),
),
);
queryClient.setQueryData<ChannelDetail | undefined>(
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);
},
@@ -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({
</div>
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
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.`}
</div>
</DialogContent>
</Dialog>
@@ -375,6 +382,11 @@ function ChannelCard({
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{channel.channelType}
</p>
{channel.archivedAt ? (
<p className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
archived
</p>
) : null}
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
+11
View File
@@ -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();
});
+10 -1
View File
@@ -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();
});
+25 -22
View File
@@ -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);
+4 -3
View File
@@ -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(