mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user