Improve ephemeral channel affordances and hide archived sidebar rows (#286)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-09 18:08:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3878574273
commit b71f50400c
10 changed files with 467 additions and 18 deletions
@@ -0,0 +1,167 @@
import type { Channel } from "@/shared/api/types";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto",
});
const absoluteTimeFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
type EphemeralChannelLike = Pick<Channel, "ttlSeconds" | "ttlDeadline">;
export const EPHEMERAL_CHANNEL_LABEL = "Ephemeral";
export type EphemeralChannelDisplay = {
detailLabel: string | null;
tooltipLabel: string;
};
export function isEphemeralChannel(channel: EphemeralChannelLike): boolean {
return channel.ttlSeconds !== null || channel.ttlDeadline !== null;
}
function resolveRemainingSeconds(
ttlDeadline: string | null,
nowMs: number,
): number | null {
if (!ttlDeadline) {
return null;
}
const deadlineMs = Date.parse(ttlDeadline);
if (Number.isNaN(deadlineMs)) {
return null;
}
return Math.ceil((deadlineMs - nowMs) / 1_000);
}
function formatCompactRemaining(remainingSeconds: number): string {
if (remainingSeconds <= 0) {
return "Cleanup due";
}
if (remainingSeconds <= 60) {
return "1m left";
}
if (remainingSeconds < 60 * 60) {
return `${Math.max(1, Math.ceil(remainingSeconds / 60))}m left`;
}
if (remainingSeconds < 60 * 60 * 24) {
return `${Math.max(1, Math.ceil(remainingSeconds / (60 * 60)))}h left`;
}
return `${Math.max(1, Math.ceil(remainingSeconds / (60 * 60 * 24)))}d left`;
}
function formatVerboseRemaining(remainingSeconds: number): string {
if (remainingSeconds <= 0) {
return "now";
}
if (remainingSeconds <= 60) {
return relativeTimeFormatter.format(1, "minute");
}
if (remainingSeconds < 60 * 60) {
return relativeTimeFormatter.format(
Math.max(1, Math.ceil(remainingSeconds / 60)),
"minute",
);
}
if (remainingSeconds < 60 * 60 * 24) {
return relativeTimeFormatter.format(
Math.max(1, Math.ceil(remainingSeconds / (60 * 60))),
"hour",
);
}
return relativeTimeFormatter.format(
Math.max(1, Math.ceil(remainingSeconds / (60 * 60 * 24))),
"day",
);
}
function formatCompactTtl(ttlSeconds: number): string {
if (ttlSeconds < 60) {
return `${Math.max(1, ttlSeconds)}s TTL`;
}
if (ttlSeconds < 60 * 60) {
return `${Math.max(1, Math.ceil(ttlSeconds / 60))}m TTL`;
}
if (ttlSeconds < 60 * 60 * 24) {
return `${Math.max(1, Math.ceil(ttlSeconds / (60 * 60)))}h TTL`;
}
return `${Math.max(1, Math.ceil(ttlSeconds / (60 * 60 * 24)))}d TTL`;
}
function formatVerboseTtl(ttlSeconds: number): string {
if (ttlSeconds < 60) {
const seconds = Math.max(1, ttlSeconds);
return `${seconds} second${seconds === 1 ? "" : "s"}`;
}
if (ttlSeconds < 60 * 60) {
const minutes = Math.max(1, Math.ceil(ttlSeconds / 60));
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
if (ttlSeconds < 60 * 60 * 24) {
const hours = Math.max(1, Math.ceil(ttlSeconds / (60 * 60)));
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
const days = Math.max(1, Math.ceil(ttlSeconds / (60 * 60 * 24)));
return `${days} day${days === 1 ? "" : "s"}`;
}
export function getEphemeralChannelDisplay(
channel: EphemeralChannelLike,
nowMs = Date.now(),
): EphemeralChannelDisplay | null {
if (!isEphemeralChannel(channel)) {
return null;
}
const remainingSeconds = resolveRemainingSeconds(channel.ttlDeadline, nowMs);
const absoluteDeadlineLabel =
channel.ttlDeadline && !Number.isNaN(Date.parse(channel.ttlDeadline))
? absoluteTimeFormatter.format(new Date(channel.ttlDeadline))
: null;
if (remainingSeconds === null) {
return {
detailLabel:
channel.ttlSeconds === null
? null
: formatCompactTtl(channel.ttlSeconds),
tooltipLabel:
channel.ttlSeconds === null
? "Ephemeral channel. Cleans up automatically after inactivity."
: `Ephemeral channel. Cleans up after ${formatVerboseTtl(
channel.ttlSeconds,
)} of inactivity.`,
};
}
const compactRemaining = formatCompactRemaining(remainingSeconds);
const verboseRemaining = formatVerboseRemaining(remainingSeconds);
return {
detailLabel: compactRemaining,
tooltipLabel:
compactRemaining === "Cleanup due"
? "Ephemeral channel. Cleanup is due now."
: absoluteDeadlineLabel
? `Ephemeral channel. Cleans up ${verboseRemaining}. Scheduled for ${absoluteDeadlineLabel}.`
: `Ephemeral channel. Cleans up ${verboseRemaining}.`,
};
}
@@ -7,6 +7,7 @@ import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHead
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
useManagedAgentsQuery,
@@ -103,10 +104,11 @@ export function ChannelScreen({
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannelId, activeReadAt, markChannelRead]);
const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader(
activeChannel,
currentPubkey,
);
const {
activeChannelTitle,
activeDmPresenceStatus,
activeChannelEphemeralDisplay,
} = useActiveChannelHeader(activeChannel, currentPubkey);
const sendMessageMutation = useSendMessageMutation(
activeChannel,
currentIdentity,
@@ -377,6 +379,27 @@ export function ChannelScreen({
};
}, [activeChannel, queryClient, resolvedMessages]);
const activeChannelEphemeralBadge = activeChannelEphemeralDisplay ? (
<EphemeralChannelBadge
display={activeChannelEphemeralDisplay}
testId="chat-ephemeral-badge"
variant="header"
/>
) : null;
const headerStatusBadge =
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<>
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
{activeChannelEphemeralBadge}
</>
) : (
activeChannelEphemeralBadge
);
return (
<>
<ChatHeader
@@ -393,14 +416,7 @@ export function ChannelScreen({
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
statusBadge={headerStatusBadge}
title={activeChannelTitle}
/>
@@ -0,0 +1,48 @@
import { Clock } from "lucide-react";
import {
EPHEMERAL_CHANNEL_LABEL,
type EphemeralChannelDisplay,
} from "@/features/channels/lib/ephemeralChannel";
import { cn } from "@/shared/lib/cn";
type EphemeralChannelBadgeProps = {
display: EphemeralChannelDisplay;
testId?: string;
variant: "header" | "sidebar";
};
export function EphemeralChannelBadge({
display,
testId,
variant,
}: EphemeralChannelBadgeProps) {
const isHeader = variant === "header";
const accessibilityProps = isHeader
? {}
: {
"aria-label": display.tooltipLabel,
role: "img" as const,
};
const label =
isHeader && display.detailLabel
? `${EPHEMERAL_CHANNEL_LABEL} · ${display.detailLabel}`
: EPHEMERAL_CHANNEL_LABEL;
return (
<span
{...accessibilityProps}
className={cn(
"inline-flex items-center gap-1 rounded-full font-medium text-amber-700 dark:text-amber-300",
isHeader
? "border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs"
: "shrink-0 h-4 w-4 justify-center border border-amber-500/20 bg-amber-500/10 p-0",
)}
data-testid={testId}
title={display.tooltipLabel}
>
<Clock className={cn(isHeader ? "h-3 w-3" : "h-2.5 w-2.5")} />
{isHeader ? <span>{label}</span> : null}
</span>
);
}
@@ -1,5 +1,6 @@
import * as React from "react";
import { useEphemeralChannelDisplay } from "@/features/channels/useEphemeralChannelDisplay";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
@@ -24,6 +25,8 @@ export function useActiveChannelHeader(
const activeDmProfilesQuery = useUsersBatchQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeChannelEphemeralDisplay =
useEphemeralChannelDisplay(activeChannel);
const activeDmPresenceStatus: PresenceStatus | null =
activeDmParticipantPubkeys.length > 0
? (activeDmPresenceQuery.data?.[
@@ -40,5 +43,6 @@ export function useActiveChannelHeader(
)
: "Channels",
activeDmPresenceStatus,
activeChannelEphemeralDisplay,
};
}
@@ -0,0 +1,28 @@
import * as React from "react";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import type { Channel } from "@/shared/api/types";
export function useEphemeralChannelDisplay(channel: Channel | null) {
const [, setClockTick] = React.useState(0);
const deadlineKey =
channel?.ttlDeadline === null || channel === null
? null
: `${channel.id}:${channel.ttlDeadline}`;
React.useEffect(() => {
if (deadlineKey === null) {
return;
}
const intervalId = window.setInterval(() => {
setClockTick((current) => current + 1);
}, 60_000);
return () => {
window.clearInterval(intervalId);
};
}, [deadlineKey]);
return channel ? getEphemeralChannelDisplay(channel, Date.now()) : null;
}
+7 -3
View File
@@ -65,19 +65,23 @@ export function ChatHeader({
data-tauri-drag-region
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ChannelIcon
channelType={channelType}
mode={mode}
visibility={visibility}
/>
<h1
className="truncate text-lg font-semibold tracking-tight"
className="min-w-0 truncate text-lg font-semibold tracking-tight"
data-testid="chat-title"
>
{title}
</h1>
{statusBadge ? <div className="shrink-0">{statusBadge}</div> : null}
{statusBadge ? (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{statusBadge}
</div>
) : null}
</div>
<p
className="truncate text-sm text-muted-foreground"
@@ -550,14 +550,17 @@ export function AppSidebar({
const streamForm = useCreateForm(onCreateChannel, "stream");
const forumForm = useCreateForm(onCreateForum, "forum");
const visibleChannels = channels.filter(
(channel) => channel.archivedAt === null,
);
const streamChannels = channels.filter(
const streamChannels = visibleChannels.filter(
(channel) => channel.channelType === "stream",
);
const forumChannels = channels.filter(
const forumChannels = visibleChannels.filter(
(channel) => channel.channelType === "forum",
);
const directMessages = channels.filter(
const directMessages = visibleChannels.filter(
(channel) => channel.channelType === "dm",
);
const isSelectedDirectMessage =
@@ -1,6 +1,8 @@
import type * as React from "react";
import { CircleDot, FileText, Hash, Lock, X } from "lucide-react";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -139,6 +141,7 @@ export function ChannelMenuButton({
onSelectChannel: (channelId: string) => void;
}) {
const resolvedLabel = label ?? channel.name;
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
return (
<SidebarMenuButton
@@ -159,6 +162,13 @@ export function ChannelMenuButton({
presenceStatus={presenceStatus}
/>
<span className="min-w-0 flex-1 truncate">{resolvedLabel}</span>
{ephemeralDisplay ? (
<EphemeralChannelBadge
display={ephemeralDisplay}
testId={`channel-ephemeral-${channel.name}`}
variant="sidebar"
/>
) : null}
{hasUnread && !isActive && channel.channelType !== "dm" ? (
<span
aria-hidden="true"
+20
View File
@@ -92,6 +92,8 @@ type RawChannel = {
archived_at: string | null;
participants: string[];
participant_pubkeys: string[];
ttl_seconds: number | null;
ttl_deadline: string | null;
};
type RawChannelWithMembership = RawChannel & {
@@ -465,6 +467,8 @@ function toRawChannel(channel: MockChannel): RawChannelWithMembership {
archived_at: channel.archived_at,
participants: [...channel.participants],
participant_pubkeys: [...channel.participant_pubkeys],
ttl_seconds: channel.ttl_seconds ?? null,
ttl_deadline: channel.ttl_deadline ?? null,
is_member: channel.members.some(
(member) =>
member.pubkey === MOCK_IDENTITY_PUBKEY ||
@@ -511,11 +515,15 @@ function createMockChannel(
| "updated_at"
| "participant_pubkeys"
| "participants"
| "ttl_seconds"
| "ttl_deadline"
> & {
created_minutes_ago: number;
members: RawChannelMember[];
participant_pubkeys?: string[];
participants?: string[];
ttl_seconds?: number | null;
ttl_deadline?: string | null;
updated_minutes_ago?: number;
},
): MockChannel {
@@ -526,6 +534,8 @@ function createMockChannel(
members: cloneMembers(seed.members),
participant_pubkeys: [...(seed.participant_pubkeys ?? [])],
participants: [...(seed.participants ?? [])],
ttl_seconds: seed.ttl_seconds ?? null,
ttl_deadline: seed.ttl_deadline ?? null,
updated_at: isoMinutesAgo(
seed.updated_minutes_ago ?? seed.created_minutes_ago,
),
@@ -2181,10 +2191,15 @@ async function handleCreateChannel(
channelType: "stream" | "forum";
visibility: "open" | "private";
description?: string;
ttlSeconds?: number;
},
config: E2eConfig | undefined,
) {
const identity = getIdentity(config);
const ttlDeadline =
typeof args.ttlSeconds === "number"
? new Date(Date.now() + args.ttlSeconds * 1_000).toISOString()
: null;
if (!identity) {
const owner = createCurrentMember(config, "owner");
const channel = createMockChannel({
@@ -2202,6 +2217,8 @@ async function handleCreateChannel(
topic_set_at: null,
purpose_set_by: null,
purpose_set_at: null,
ttl_seconds: args.ttlSeconds ?? null,
ttl_deadline: ttlDeadline,
topic_required: false,
max_members: null,
nip29_group_id: null,
@@ -2223,6 +2240,9 @@ async function handleCreateChannel(
if (args.description) {
tags.push(["about", args.description]);
}
if (typeof args.ttlSeconds === "number") {
tags.push(["ttl", String(args.ttlSeconds)]);
}
await submitSignedEvent(config, { kind: 9007, content: "", tags });
// Fetch the created channel to return the expected shape.
+149
View File
@@ -122,6 +122,155 @@ test("create stream with name and description", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("create ephemeral stream shows sidebar and header affordances", async ({
page,
}) => {
const channelName = `ephemeral-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-stream-form")
.getByLabel("Ephemeral — auto-archives after 1 day of inactivity")
.click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveText(
/Ephemeral.+left/,
);
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
});
test("ephemeral countdown refreshes when switching channels after a clock jump", async ({
page,
}) => {
const firstChannelName = "ephemeral-alpha";
const secondChannelName = "ephemeral-beta";
const initialTime = new Date("2026-04-09T00:00:00.000Z");
const shiftedTime = new Date("2026-04-09T02:00:00.000Z");
await page.clock.setFixedTime(initialTime);
await page.goto("/");
for (const channelName of [firstChannelName, secondChannelName]) {
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page
.getByTestId("create-stream-description")
.fill("Auto-cleaned test stream");
await page
.getByTestId("create-stream-form")
.getByLabel("Ephemeral — auto-archives after 1 day of inactivity")
.click();
await page
.getByTestId("create-stream-form")
.getByRole("button", { name: "Create" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
}
await page.clock.setFixedTime(shiftedTime);
await expect
.poll(() => page.evaluate(() => Date.now()))
.toBe(shiftedTime.getTime());
await page.getByTestId(`channel-${firstChannelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(firstChannelName);
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveText(
/Ephemeral.+22h left/,
);
});
test("archived channels stay out of all sidebar sections", async ({ page }) => {
const archivedStreamName = `archived-stream-${Date.now()}`;
const archivedForumName = `archived-forum-${Date.now()}`;
await page.goto("/");
await page.waitForFunction(() => {
return Boolean(
(
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__,
);
});
await page.evaluate(
async ({
archivedForumName,
archivedStreamName,
outsiderPubkey,
}: {
archivedForumName: string;
archivedStreamName: string;
outsiderPubkey: string;
}) => {
const invoke = (
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<{ id: string }>;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
const stream = await invoke("create_channel", {
channelType: "stream",
name: archivedStreamName,
visibility: "open",
});
const forum = await invoke("create_channel", {
channelType: "forum",
name: archivedForumName,
visibility: "open",
});
const directMessage = await invoke("open_dm", {
pubkeys: [outsiderPubkey],
});
for (const channel of [stream, forum, directMessage]) {
await invoke("archive_channel", { channelId: channel.id });
}
},
{
archivedForumName,
archivedStreamName,
outsiderPubkey: TEST_IDENTITIES.outsider.pubkey,
},
);
await page.reload();
await expect(page.getByTestId("stream-list")).not.toContainText(
archivedStreamName,
);
await expect(page.getByTestId("forum-list")).not.toContainText(
archivedForumName,
);
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
await expect(page.getByTestId("dm-list")).not.toContainText("outsider");
});
test("create stream with special characters", async ({ page }) => {
const channelName = `dev ops-${Date.now()}`;