- {session.parentName && (
-
-
- {session.parentName}
-
- )}
-
- {session.content ?? {session.title}}
+ {sessions.map((session) => {
+ const ParentChannelIcon = channelIcon({
+ private: session.parentPrivate,
+ });
+ return (
+ onSelect(session.id)}
+ label={
+
+ {session.parentName && (
+
+
+ {session.parentName}
+
+ )}
+
+ {session.content ?? {session.title}}
+
-
- }
- />
- ))}
+ }
+ />
+ );
+ })}
{listStatus ??
(!sessions.length && (
diff --git a/src/features/channels/channel-icon.test.ts b/src/features/channels/channel-icon.test.ts
new file mode 100644
index 000000000..87610108e
--- /dev/null
+++ b/src/features/channels/channel-icon.test.ts
@@ -0,0 +1,9 @@
+import { expect, it } from "vitest";
+import { HashIcon, LockIcon } from "../../shared/design-system/icons/index";
+import { channelIcon } from "./channel-icon";
+
+it("uses a lock only for private channels", () => {
+ expect(channelIcon({ private: true })).toBe(LockIcon);
+ expect(channelIcon({})).toBe(HashIcon);
+ expect(channelIcon(undefined)).toBe(HashIcon);
+});
diff --git a/src/features/channels/channel-icon.ts b/src/features/channels/channel-icon.ts
new file mode 100644
index 000000000..10ec1dd9c
--- /dev/null
+++ b/src/features/channels/channel-icon.ts
@@ -0,0 +1,8 @@
+import { HashIcon, LockIcon } from "../../shared/design-system/icons/index";
+
+/** Ordinary channel visibility chooses its icon; callers retain non-channel type icons. */
+export function channelIcon(
+ channel: { readonly private?: true | undefined } | undefined,
+) {
+ return channel?.private ? LockIcon : HashIcon;
+}
diff --git a/src/features/conversation/BuzzLinkPreview.tsx b/src/features/conversation/BuzzLinkPreview.tsx
index 2aef1348f..3d13d0e88 100644
--- a/src/features/conversation/BuzzLinkPreview.tsx
+++ b/src/features/conversation/BuzzLinkPreview.tsx
@@ -2,7 +2,8 @@ import { useIdentityNames } from "../identity-names/react";
import { useEffect, useState, useSyncExternalStore } from "react";
import type { RelaySession } from "../relay/session";
import type { ThreadView } from "../relay/threads";
-import { HashIcon, LockIcon } from "../../shared/design-system/icons/index";
+import { LockIcon } from "../../shared/design-system/icons/index";
+import { channelIcon } from "../channels/channel-icon";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { relativeTimestamp } from "../../shared/relative-timestamp";
import styles from "./LinkPreview.module.css";
@@ -112,7 +113,9 @@ function PreviewContent({
.join(", ") || "Notes to self"
: (channel?.name ?? "Channel unavailable");
const ChannelIcon =
- channel?.hidden || channel?.channelType === "dm" ? LockIcon : HashIcon;
+ channel?.hidden || channel?.channelType === "dm"
+ ? LockIcon
+ : channelIcon(channel);
return (
<>
diff --git a/src/features/conversation/LinkLabelContext.ts b/src/features/conversation/LinkLabelContext.ts
index df1fcc78f..e81163daf 100644
--- a/src/features/conversation/LinkLabelContext.ts
+++ b/src/features/conversation/LinkLabelContext.ts
@@ -5,3 +5,6 @@ export const LinkLabelContext = createContext(undefined);
/** Authored Markdown formatting stays inside the host anchor. */
export const LinkContentContext = createContext(undefined);
+
+/** Host-resolved visibility for channel links rendered by a shared link renderer. */
+export const LinkChannelPrivateContext = createContext(false);
diff --git a/src/features/conversation/MessageLink.tsx b/src/features/conversation/MessageLink.tsx
index 37dbcabbd..63cb03354 100644
--- a/src/features/conversation/MessageLink.tsx
+++ b/src/features/conversation/MessageLink.tsx
@@ -11,7 +11,11 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary";
import { PreviewCard } from "../../shared/design-system/ui/PreviewCard";
import type { RelaySession } from "../relay/session";
import { parseBuzzLink, isBuzzLink } from "../navigation/buzz-links";
-import { LinkLabelContext, LinkContentContext } from "./LinkLabelContext";
+import {
+ LinkChannelPrivateContext,
+ LinkLabelContext,
+ LinkContentContext,
+} from "./LinkLabelContext";
import { BuzzLinkPreview } from "./BuzzLinkPreview";
import { messageViewKey } from "../messages/view-key";
import styles from "./LinkPreview.module.css";
@@ -44,6 +48,7 @@ export function MessageLink({
session,
scope,
interactive = true,
+ channelPrivate = false,
}: {
url: string;
children?: ReactNode;
@@ -53,6 +58,7 @@ export function MessageLink({
session?: RelaySession | undefined;
scope?: string | undefined;
interactive?: boolean;
+ channelPrivate?: boolean;
}) {
const renderers = useSyncExternalStore(
registry?.subscribe ?? subscribe,
@@ -177,8 +183,10 @@ export function MessageLink({
>
);
return (
-
- {result}
-
+
+
+ {result}
+
+
);
}
diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx
index 667c89e1b..552c666f8 100644
--- a/src/features/messages/MessageMarkdown.test.tsx
+++ b/src/features/messages/MessageMarkdown.test.tsx
@@ -6,7 +6,7 @@ import {
render as mount,
} from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
-import { RobotIcon } from "../../shared/design-system/icons/index";
+import { LockIcon, RobotIcon } from "../../shared/design-system/icons/index";
import referenceStyles from "../../shared/InlineReference.module.css";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
@@ -498,7 +498,7 @@ it.each([false, true])(
},
);
-it("keeps resolved channel labels for Buzz autolinks", () => {
+it("keeps resolved private channel labels and lock icons for Buzz links", () => {
const entry = {
id: "link",
title: "Links",
@@ -509,7 +509,7 @@ it("keeps resolved channel labels for Buzz autolinks", () => {
component: ({ url }: { url: string }) => ,
};
const href = `buzz://message?channel=design&id=${"a".repeat(64)}`;
- const html = render(`<${href}> `, {
+ const html = render(`<${href}> #design`, {
directory: {
profiles: new Map(),
agents: [],
@@ -518,6 +518,7 @@ it("keeps resolved channel labels for Buzz autolinks", () => {
id: "design",
name: "design",
channelType: "forum",
+ private: true,
},
],
},
@@ -531,6 +532,10 @@ it("keeps resolved channel labels for Buzz autolinks", () => {
const text = html.replace(/<[^>]*>/g, "");
expect(text).toContain("design");
expect(text).not.toContain("buzz://");
+ const lock = renderToStaticMarkup(
+ ,
+ );
+ expect(html.split(lock)).toHaveLength(3);
});
it("renders tagged agent library names and profile names with the same agent icon", () => {
diff --git a/src/features/messages/MessageMarkdown.tsx b/src/features/messages/MessageMarkdown.tsx
index eadb524f1..16229284e 100644
--- a/src/features/messages/MessageMarkdown.tsx
+++ b/src/features/messages/MessageMarkdown.tsx
@@ -13,6 +13,7 @@ import { parseBuzzLink } from "../navigation/buzz-links";
import { messageLinkParts, normalizeWrappedLinks } from "./message-link-parts";
import {
ReferenceText,
+ channelForLink,
channelLinkLabel,
emptyReferenceDirectory,
} from "./ReferenceText";
@@ -306,19 +307,23 @@ export function MessageMarkdown({
row = { ...row, content: normalized };
scan = scanMarkdown(normalized);
}
- const renderLink = (url: string, label?: string, children?: ReactNode) => (
-
- {children}
-
- );
+ const renderLink = (url: string, label?: string, children?: ReactNode) => {
+ const channel = channelForLink(url, scope, directory.channels);
+ return (
+
+ {children}
+
+ );
+ };
const renderInline = (text: string) =>
extensions ? (
item.id === target.channelId);
+ return target && channels.find((item) => item.id === target.channelId);
+}
+
+export function channelLinkLabel(
+ url: string,
+ scope: string | undefined,
+ channels: readonly ChannelSummary[],
+) {
+ const channel = channelForLink(url, scope, channels);
+ const parsed = parseBuzzLink(url);
+ const target =
+ parsed?.format === "legacy"
+ ? parsed
+ : parsed?.target.kind === "conversation"
+ ? parsed.target
+ : undefined;
return channel
- ? `${channel.channelType === "dm" || target.messageId ? "" : "#"}${channel.name}`
+ ? `${channel.channelType === "dm" || target?.messageId ? "" : "#"}${channel.name}`
: undefined;
}
@@ -120,6 +134,7 @@ export function ReferenceText({
session={session}
scope={scope}
interactive={interactive}
+ channelPrivate={!!reference.private}
/>
) : (
{
+ const refs = messageReferences(
+ "Ask in #secret.",
+ [],
+ profiles,
+ [{ id: "secret", name: "secret", private: true }],
+ agents,
+ );
+ expect(refs).toMatchObject([
+ { label: "#secret", kind: "channel", id: "secret", private: true },
+ ]);
+});
it("resolves channel labels only in the receiving community", () => {
const scope = `https://local.example:${person}`;
expect(
diff --git a/src/features/messages/message-references.ts b/src/features/messages/message-references.ts
index afead5d8d..71f04edd1 100644
--- a/src/features/messages/message-references.ts
+++ b/src/features/messages/message-references.ts
@@ -7,6 +7,7 @@ export type MessageReference = {
label: string;
kind: "channel" | "person" | "agent";
id: string;
+ private?: true;
};
/** Names are display evidence only. People must also have an explicit signed mention. */
@@ -24,6 +25,7 @@ export function messageReferences(
label: `#${channel.name}`,
kind: "channel" as const,
id: channel.id,
+ ...(channel.private ? { private: true as const } : {}),
})),
...mentions.flatMap((id) => {
const agent = agents.find((entry) => entry.pubkey === id);
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 4efc2ebf4..55cdc8c4d 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -15,6 +15,8 @@ export type ChannelSummary = Readonly<{
hidden?: true;
/** Relay-authored metadata; absent while metadata is unavailable. */
channelType?: "stream" | "forum" | "dm" | "session";
+ /** Relay-authored channel visibility; private channels use restricted presentation. */
+ private?: true;
/** Presentation-only parent from signed channel metadata; never grants access. */
parentChannelId?: string | undefined;
/** Metadata update time used for stable work-history ordering. */
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index bd51b5a76..d5b3eefec 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -153,6 +153,10 @@ export class DiscoveryState {
const event = this.metadata.get(id);
return !!event && event.tags.some((entry) => entry[0] === "hidden");
}
+ isPrivate(id: string): boolean {
+ const event = this.metadata.get(id);
+ return !!event && event.tags.some(([name]) => name === "private");
+ }
isSession(id: string): boolean {
const event = this.metadata.get(id);
return (
@@ -191,6 +195,7 @@ export class DiscoveryState {
].sort(),
),
...(this.hidden(id) ? { hidden: true } : {}),
+ ...(this.isPrivate(id) ? { private: true } : {}),
...(channelType ? { channelType } : {}),
...(channelType === "session" && event
? {
diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts
index 947001c88..690070388 100644
--- a/src/features/relay/outbox.test.ts
+++ b/src/features/relay/outbox.test.ts
@@ -674,3 +674,85 @@ it("a failing delivered observer cannot turn a confirmed send into failure", asy
owner.dispose();
}
});
+
+it("restores a retained creation to retained storage when dismissal cannot persist", async () => {
+ const event = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [["h", "11111111-1111-4111-8111-111111111111"]],
+ });
+ const retained: OutgoingEvent = { event, signed: event, delivery: "seen" };
+ let records: readonly OutgoingEvent[] = [retained];
+ const save = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("journal unavailable"))
+ .mockImplementation(async (next: readonly OutgoingEvent[]) => {
+ records = structuredClone(next);
+ });
+ const owner = createOutbox(
+ viewer.pubkey,
+ {
+ sign: async (template) => signed(viewer, template),
+ publish: async () => {},
+ },
+ { load: () => structuredClone(records), save },
+ );
+ try {
+ await owner.ready;
+ expect(owner.outbox.snapshot()).toHaveLength(0);
+ await expect(owner.outbox.dismiss(event.id)).rejects.toThrow(
+ "journal unavailable",
+ );
+ expect(owner.outbox.snapshot()).toHaveLength(0);
+ expect(owner.local.snapshot()).toEqual([retained]);
+ await vi.waitFor(() =>
+ expect(records).toMatchObject([
+ { event: { id: event.id }, delivery: "seen" },
+ ]),
+ );
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("keeps creation receipts when an access purge races journal hydration", async () => {
+ const creation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [["h", "11111111-1111-4111-8111-111111111111"]],
+ });
+ const message = signed(viewer, {
+ kind: 9,
+ content: "private content",
+ tags: [["h", "11111111-1111-4111-8111-111111111111"]],
+ });
+ let hydrate!: (items: readonly OutgoingEvent[]) => void;
+ const owner = createOutbox(
+ viewer.pubkey,
+ {
+ sign: async (template) => signed(viewer, template),
+ publish: async () => {},
+ },
+ {
+ load: () =>
+ new Promise((resolve) => {
+ hydrate = resolve;
+ }),
+ save: async () => {},
+ },
+ );
+ try {
+ owner.purgeConfirmed((event) => event.kind === 9007);
+ hydrate([
+ { event: creation, signed: creation, delivery: "seen" },
+ { event: message, signed: message, delivery: "seen" },
+ ]);
+ await owner.ready;
+ expect(owner.outbox.snapshot()).toHaveLength(0);
+ expect(owner.local.snapshot().map(({ event }) => event.id)).toEqual([
+ creation.id,
+ ]);
+ } finally {
+ owner.dispose();
+ }
+});
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 96eb6f484..bc9947472 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -130,6 +130,7 @@ export function createOutbox(
[...attempts.values()].filter((attempt) => attempt.controller).length;
let closed = false;
let confirmedInvalidated = false;
+ let confirmedKeep: ((event: EventData) => boolean) | undefined;
let storageError: string | undefined;
let durable = Promise.resolve();
const notify = () => {
@@ -227,7 +228,11 @@ export function createOutbox(
if (pending.length > MAX_PENDING)
throw new Error("Saved pending outbox exceeds its budget");
for (const item of restored)
- if (item.delivery === "seen" && !confirmedInvalidated)
+ if (
+ item.delivery === "seen" &&
+ (!confirmedInvalidated ||
+ (item.event.kind === 9007 && confirmedKeep?.(item.event)))
+ )
completed.set(item.event.id, item);
snapshot = Object.freeze([...pending, ...snapshot]);
notify();
@@ -517,17 +522,21 @@ export function createOutbox(
if (closed || attempts.has(id)) return;
deliveryWork.delete(id);
const retained = completed.peek(id);
- const previous =
- find(id) ??
- (retained && awaitsReceipt(retained.event) ? retained : undefined);
- if (previous && awaitsReceipt(previous.event)) completed.delete(id);
+ const pending = find(id);
+ if (retained) completed.delete(id);
snapshot = Object.freeze(snapshot.filter((item) => item.event.id !== id));
notify();
try {
await persist(id);
} catch (error) {
- if (!closed && previous && !find(id)) {
- snapshot = Object.freeze([...snapshot, previous]);
+ if (
+ !closed &&
+ !find(id) &&
+ !completed.peek(id) &&
+ (pending || retained)
+ ) {
+ if (pending) snapshot = Object.freeze([...snapshot, pending]);
+ if (retained) completed.set(id, retained);
notify();
void persist(id).catch(() => {});
}
@@ -546,6 +555,7 @@ export function createOutbox(
* during async hydration fences that old confirmed cache, not its writes. */
purgeConfirmed(keep: (event: EventData) => boolean) {
confirmedInvalidated = true;
+ confirmedKeep = keep;
const removed = completed
.entries()
.filter(([, item]) => !keep(item.event))
diff --git a/src/features/relay/session-discovery.test.ts b/src/features/relay/session-discovery.test.ts
index 666e444ad..e2e45cf2b 100644
--- a/src/features/relay/session-discovery.test.ts
+++ b/src/features/relay/session-discovery.test.ts
@@ -29,7 +29,10 @@ it("recognizes only relay-authorized private channel session metadata and keeps
discovery.accept(metadata(relay, false, 1));
expect(discovery.isSession("work")).toBe(false);
discovery.accept(metadata(relay, true, 2));
- expect(discovery.channels()[0]?.channelType).toBe("session");
+ expect(discovery.channels()[0]).toMatchObject({
+ channelType: "session",
+ private: true,
+ });
discovery.accept(
signed(relay, {
kind: 39000,
@@ -69,6 +72,7 @@ it("restores an ordinary child from signed metadata without inheriting parent ac
expect(discovery.channels().find((item) => item.id === child)).toMatchObject({
channelType: "session",
parentChannelId: parent,
+ private: true,
});
discovery.accept(roster(relay, parent, [], 1800000000));
expect(discovery.authorized(parent)).toBe(false);
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index ff411a6e6..fc2429ab6 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -2,6 +2,7 @@
import { createPresence } from "../presence/presence";
import type { PresenceActivity } from "../presence/activity";
import { bindNames, type IdentityNames } from "../identity-names/service";
+import { sessionMetadata } from "../sessions/metadata";
import { createWorkflows } from "../workflows/capability";
import { isWorkflowOperation } from "../workflows/protocol";
import {
@@ -46,6 +47,8 @@ import {
PublishRejected,
browserOutboxStorage,
createOutbox,
+ type LocalEvents,
+ type OutgoingEvent,
type OutboxStorage,
} from "./outbox";
import { createMessages } from "./messages";
@@ -64,6 +67,91 @@ export type EventViewSnapshot = Readonly<{
events: readonly VisibleEvent[];
error?: string | undefined;
}>;
+
+type ChannelCreationInput = Readonly<{
+ name: string;
+ description?: string | undefined;
+ visibility: "open" | "private";
+ ttlSeconds?: number | undefined;
+}>;
+type PendingChannelCreation = Readonly<{
+ signature: string;
+ id: string;
+ operation: string;
+ input: ChannelCreationInput;
+}>;
+const channelId =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+function restoredChannelCreation(
+ events: LocalEvents | undefined,
+): PendingChannelCreation | undefined {
+ for (const item of [...(events?.snapshot() ?? [])].reverse()) {
+ const restored = parseChannelCreation(item);
+ if (restored) return restored;
+ }
+}
+
+function parseChannelCreation(
+ item: OutgoingEvent,
+): PendingChannelCreation | undefined {
+ if (item.event.kind !== 9007) return;
+ const tags = item.event.tags.filter(([name]) => name !== "client-id");
+ const [h, name, visibility, channelType, ...optional] = tags;
+ const id = h?.[1];
+ const channelName = name?.[1];
+ const channelVisibility = visibility?.[1];
+ const optionalNames = optional.map(([key]) => key);
+ const validOptionalOrder = [[], ["about"], ["ttl"], ["about", "ttl"]].some(
+ (names) =>
+ names.length === optionalNames.length &&
+ names.every((key, index) => key === optionalNames[index]),
+ );
+ if (
+ item.event.content !== "" ||
+ h?.length !== 2 ||
+ h[0] !== "h" ||
+ id === undefined ||
+ !channelId.test(id) ||
+ name?.length !== 2 ||
+ name[0] !== "name" ||
+ channelName === undefined ||
+ !channelName.trim() ||
+ visibility?.length !== 2 ||
+ visibility[0] !== "visibility" ||
+ channelVisibility === undefined ||
+ !["open", "private"].includes(channelVisibility) ||
+ channelType?.length !== 2 ||
+ channelType[0] !== "channel_type" ||
+ channelType[1] !== "stream" ||
+ !validOptionalOrder ||
+ optional.some((tag) => tag.length !== 2)
+ )
+ return;
+ const description = optional.find(([key]) => key === "about")?.[1]?.trim();
+ if (sessionMetadata(description) !== undefined) return;
+ const ttlValue = optional.find(([key]) => key === "ttl")?.[1];
+ const ttlSeconds = ttlValue === undefined ? undefined : Number(ttlValue);
+ if (
+ ttlSeconds !== undefined &&
+ (!Number.isInteger(ttlSeconds) ||
+ ttlSeconds <= 0 ||
+ ttlSeconds > 2_147_483_647)
+ )
+ return;
+ const input: ChannelCreationInput = Object.freeze({
+ name: channelName.trim(),
+ visibility: channelVisibility as "open" | "private",
+ ...(description ? { description } : {}),
+ ...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
+ });
+ return Object.freeze({
+ signature: JSON.stringify(input),
+ id,
+ operation: item.event.id,
+ input,
+ });
+}
/** Compose once per relay/viewer. Plugins get one interface; the host owns disposal. */
export function createRelaySession(
transport: ReadTransport | null,
@@ -211,7 +299,13 @@ export function createRelaySession(
// Purge before notifying: callbacks must not be able to reseed denied data.
for (const id of revoked) recent.delete(id);
channels.purgeAccess((events) => events.filter(visibility(events)));
- writes?.purgeConfirmed((event) => event.kind !== 0 && visible(event));
+ // An undismissed ordinary creation receipt is recovery intent until
+ // creator membership completes. Session receipts remain revocable.
+ writes?.purgeConfirmed(
+ (event) =>
+ !!parseChannelCreation({ event, delivery: "seen" }) ||
+ (event.kind !== 0 && visible(event)),
+ );
profiles.clear();
emoji.clear();
agentLibrary.clear();
@@ -692,6 +786,107 @@ export function createRelaySession(
!!transport?.decodeSidebarPreferences,
notify,
);
+ const workSessions = createWorkSessions(
+ writes?.outbox,
+ channels.queries,
+ verified,
+ lifetime.signal,
+ writes?.local,
+ async (id) => {
+ if (!transport) return false;
+ // Confirm only this viewer's exact creation receipt. Discovery may be
+ // incomplete; this never admits the channel or grants content access.
+ const events = await requests.reader.read(
+ [{ kinds: [9007], ids: [id], authors: [transport.viewer], limit: 1 }],
+ { signal: lifetime.signal, fresh: true },
+ );
+ return events.some(
+ (event) =>
+ event.id === id &&
+ event.kind === 9007 &&
+ event.pubkey === transport.viewer,
+ );
+ },
+ () => {
+ const library = agentLibrary.queries.snapshot();
+ return library.status === "ready"
+ ? library.identities.map((agent) => agent.pubkey)
+ : [];
+ },
+ transport?.relayAuthor,
+ );
+ let pendingChannelCreation: PendingChannelCreation | undefined =
+ restoredChannelCreation(writes?.local);
+ let restoredOperation = pendingChannelCreation?.operation;
+ const pendingCreation = () => {
+ const restored = restoredChannelCreation(writes?.local);
+ if (restored?.operation !== restoredOperation) {
+ restoredOperation = restored?.operation;
+ pendingChannelCreation = restored;
+ }
+ return pendingChannelCreation;
+ };
+ const channelCreation = Object.freeze({
+ available: workSessions.available,
+ subscribe: (listener: () => void) =>
+ writes?.local.subscribe(listener) ?? (() => {}),
+ snapshot: () => pendingCreation()?.input,
+ async create(input: ChannelCreationInput) {
+ if (!transport) throw new Error("The community connection changed.");
+ const normalized: ChannelCreationInput = {
+ name: input.name.trim(),
+ visibility: input.visibility,
+ ...(input.description?.trim()
+ ? { description: input.description.trim() }
+ : {}),
+ ...(input.ttlSeconds !== undefined
+ ? { ttlSeconds: input.ttlSeconds }
+ : {}),
+ };
+ const signature = JSON.stringify(normalized);
+ await writes?.ready;
+ const existing = pendingCreation();
+ if (existing?.signature !== signature) {
+ if (existing)
+ throw new Error(
+ "Another channel is still awaiting confirmation. Retry it before changing the details.",
+ );
+ const id = crypto.randomUUID();
+ pendingChannelCreation = {
+ signature,
+ id,
+ input: Object.freeze(normalized),
+ operation: workSessions.createChannel(
+ id,
+ normalized.name,
+ normalized.visibility,
+ normalized.description,
+ normalized.ttlSeconds,
+ ),
+ };
+ restoredOperation = pendingChannelCreation.operation;
+ }
+ const pending = pendingChannelCreation;
+ if (!pending) throw new Error("Channel creation could not be prepared.");
+ try {
+ await workSessions.delivered(pending.operation);
+ await workSessions.refresh(
+ pending.id,
+ { member: transport.viewer },
+ false,
+ );
+ await writes?.outbox.dismiss(pending.operation);
+ pendingChannelCreation = undefined;
+ return pending.id;
+ } catch (error) {
+ if (workSessions.failed(pending.operation)) {
+ await workSessions.discardFailed(pending.operation);
+ pendingChannelCreation = undefined;
+ }
+ throw error;
+ }
+ },
+ });
const session = Object.freeze({
presence,
viewer: transport?.viewer,
@@ -704,35 +899,8 @@ export function createRelaySession(
};
},
typing: typing.capability,
- workSessions: createWorkSessions(
- writes?.outbox,
- channels.queries,
- verified,
- lifetime.signal,
- writes?.local,
- async (id) => {
- if (!transport) return false;
- // Confirm only this viewer's exact creation receipt. Discovery may be
- // incomplete; this never admits the channel or grants content access.
- const events = await requests.reader.read(
- [{ kinds: [9007], ids: [id], authors: [transport.viewer], limit: 1 }],
- { signal: lifetime.signal, fresh: true },
- );
- return events.some(
- (event) =>
- event.id === id &&
- event.kind === 9007 &&
- event.pubkey === transport.viewer,
- );
- },
- () => {
- const library = agentLibrary.queries.snapshot();
- return library.status === "ready"
- ? library.identities.map((agent) => agent.pubkey)
- : [];
- },
- transport?.relayAuthor,
- ),
+ channelCreation,
+ workSessions,
unread: unread.capability,
sidebarPreferences: sidebarPreferences.queries,
live,
diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts
index 78a89c441..de3f0e8fa 100644
--- a/src/features/relay/store.test.ts
+++ b/src/features/relay/store.test.ts
@@ -69,6 +69,32 @@ describe("channel store", () => {
failing.queries.ensureList();
expect(failing.queries.list().status).toBe("loading");
});
+ it("replaces a channel summary when only its private visibility changes", async () => {
+ const { store, queries, next } = setup();
+ queries.ensureList();
+ next().respond([
+ roster(relay, "work", [viewer.pubkey]),
+ metadata(relay, "work", "Work"),
+ ]);
+ await flush();
+ const publicSummary = queries.list().channels[0];
+ expect(publicSummary?.private).toBeUndefined();
+
+ queries.refreshList?.();
+ next().respond([
+ roster(relay, "work", [viewer.pubkey]),
+ signed(relay, {
+ kind: 39000,
+ content: JSON.stringify({ name: "Work" }),
+ created_at: 1_700_000_001,
+ tags: [["d", "work"], ["name", "Work"], ["private"]],
+ }),
+ ]);
+ await flush();
+ expect(queries.list().channels[0]).not.toBe(publicSummary);
+ expect(queries.list().channels[0]?.private).toBe(true);
+ store.dispose();
+ });
it("pages a window by the relay cursor, prepends older rows, and fetches missing profiles once", async () => {
const { store, queries, next, pending } = setup();
const listener = vi.fn();
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index 5ab95d2c7..22560e705 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -174,6 +174,7 @@ export function createChannelStore(
old.name === channel.name &&
old.preview === preview &&
old.hidden === channel.hidden &&
+ old.private === channel.private &&
old.channelType === channel.channelType &&
old.parentChannelId === channel.parentChannelId &&
old.updatedAt === channel.updatedAt &&
diff --git a/src/features/relay/work-sessions.test.ts b/src/features/relay/work-sessions.test.ts
index f45779cd7..febc64860 100644
--- a/src/features/relay/work-sessions.test.ts
+++ b/src/features/relay/work-sessions.test.ts
@@ -3,11 +3,405 @@ import { SESSION_CHANNEL_DESCRIPTION } from "../sessions/metadata";
import { createWorkSessions } from "./work-sessions";
import type { Outbox, OutgoingEvent } from "./outbox";
import type { ChannelQueries } from "./contracts";
-import { keypair, message, roster, signed } from "./testing";
+import {
+ keypair,
+ message,
+ roster,
+ flush,
+ scriptedTransport,
+ signed,
+} from "./testing";
import { createRelaySession } from "./session";
import { PublishRejected } from "./outbox";
const event = message(keypair(), "session", "Work", 1);
+it("restores an unconfirmed ordinary channel without creating a second identity", async () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const id = "11111111-1111-4111-8111-111111111111";
+ const creation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Release notes"],
+ ["visibility", "private"],
+ ["channel_type", "stream"],
+ ["about", "Updates for the team"],
+ ["ttl", "604800"],
+ ],
+ });
+ const sessionCreation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", "22222222-2222-4222-8222-222222222222"],
+ ["name", "Work"],
+ ["visibility", "private"],
+ ["channel_type", "stream"],
+ ["about", SESSION_CHANNEL_DESCRIPTION],
+ ],
+ });
+ let records: readonly OutgoingEvent[] = [
+ { event: creation, signed: creation, delivery: "unknown" },
+ {
+ event: sessionCreation,
+ signed: sessionCreation,
+ delivery: "accepted",
+ },
+ ];
+ const sign = vi.fn(async () => creation);
+ const publish = vi.fn(async () => {
+ throw new Error("acknowledgement lost");
+ });
+ const owner = createRelaySession(
+ {
+ viewer: viewer.pubkey,
+ relayAuthor: relay.pubkey,
+ media: () => undefined,
+ query: async () => [],
+ writer: { kinds: [9, 9000, 9007], sign, publish },
+ },
+ {
+ outboxStorage: {
+ load: () => structuredClone(records),
+ save: (next) => {
+ records = structuredClone(next);
+ },
+ },
+ },
+ );
+ try {
+ await vi.waitFor(() =>
+ expect(owner.session.channelCreation.snapshot()).toEqual({
+ name: "Release notes",
+ description: "Updates for the team",
+ visibility: "private",
+ ttlSeconds: 604800,
+ }),
+ );
+ await expect(
+ owner.session.channelCreation.create({
+ name: "Different",
+ visibility: "open",
+ }),
+ ).rejects.toThrow(/still awaiting confirmation/);
+ await expect(
+ owner.session.channelCreation.create({
+ name: "Release notes",
+ description: "Updates for the team",
+ visibility: "private",
+ ttlSeconds: 604800,
+ }),
+ ).rejects.toThrow(/acknowledgement lost/);
+ expect(sign).not.toHaveBeenCalled();
+ expect(publish).toHaveBeenCalledOnce();
+ expect(
+ owner.session.outbox
+ ?.snapshot()
+ .filter((item) => item.event.id === creation.id),
+ ).toHaveLength(1);
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("retains a seen channel creation while creator membership is unconfirmed", async () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const id = "11111111-1111-4111-8111-111111111111";
+ const creation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Release notes"],
+ ["visibility", "open"],
+ ["channel_type", "stream"],
+ ],
+ });
+ let records: readonly OutgoingEvent[] = [
+ { event: creation, signed: creation, delivery: "seen" },
+ ];
+ const sign = vi.fn(async () => creation);
+ const publish = vi.fn(async () => {});
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ const owner = createRelaySession(
+ {
+ ...wire.transport,
+ writer: { kinds: [9, 9000, 9007], sign, publish },
+ },
+ {
+ outboxStorage: {
+ load: () => structuredClone(records),
+ save: (next) => {
+ records = structuredClone(next);
+ },
+ },
+ },
+ );
+ const input = { name: "Release notes", visibility: "open" as const };
+ try {
+ await vi.waitFor(() =>
+ expect(owner.session.channelCreation.snapshot()).toEqual(input),
+ );
+ const creating = owner.session.channelCreation.create(input);
+ await vi.waitFor(() => expect(wire.pending.length).toBeGreaterThan(0));
+ const publicMetadata = signed(relay, {
+ kind: 39000,
+ content: JSON.stringify({ name: "Release notes" }),
+ tags: [["d", id], ["name", "Release notes"], ["public"]],
+ });
+ wire.next().respond([publicMetadata]);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.get?.(id)?.readOnly).toBe(true),
+ );
+ expect(sign).not.toHaveBeenCalled();
+ expect(publish).not.toHaveBeenCalled();
+ expect(owner.session.channelCreation.snapshot()).toEqual(input);
+ expect(records).toContainEqual(
+ expect.objectContaining({
+ event: expect.objectContaining({ id: creation.id }),
+ delivery: "seen",
+ }),
+ );
+
+ owner.session.channels.refreshList?.();
+ await vi.waitFor(() => expect(wire.pending.length).toBeGreaterThan(0));
+ wire.next().respond([roster(relay, id, [viewer.pubkey]), publicMetadata]);
+ await expect(creating).resolves.toBe(id);
+ await vi.waitFor(() =>
+ expect(records.some(({ event }) => event.id === creation.id)).toBe(false),
+ );
+ expect(owner.session.channelCreation.snapshot()).toBeUndefined();
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("keeps a seen private creation through an access purge and reconnect", async () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const id = "11111111-1111-4111-8111-111111111111";
+ const creation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Private notes"],
+ ["visibility", "private"],
+ ["channel_type", "stream"],
+ ],
+ });
+ const sessionCreation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", "22222222-2222-4222-8222-222222222222"],
+ ["name", "Old session"],
+ ["visibility", "private"],
+ ["channel_type", "stream"],
+ ["about", SESSION_CHANNEL_DESCRIPTION],
+ ],
+ });
+ const input = { name: "Private notes", visibility: "private" as const };
+ let records: readonly OutgoingEvent[] = [
+ { event: creation, signed: creation, delivery: "seen" },
+ { event: sessionCreation, signed: sessionCreation, delivery: "seen" },
+ ];
+ const storage = {
+ load: () => structuredClone(records),
+ save: (next: readonly OutgoingEvent[]) => {
+ records = structuredClone(next);
+ },
+ };
+ const sign = vi.fn(async () => creation);
+ const publish = vi.fn(async () => {});
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ const owner = createRelaySession(
+ { ...wire.transport, writer: { kinds: [9007], sign, publish } },
+ { outboxStorage: storage },
+ );
+ try {
+ await vi.waitFor(() =>
+ expect(owner.session.channelCreation.snapshot()).toEqual(input),
+ );
+ owner.session.channels.ensureList();
+ wire.next().respond([]); // Complete roster does not yet include the creator.
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ expect(owner.session.channels.list().channels).toHaveLength(0);
+ expect(owner.session.channelCreation.snapshot()).toEqual(input);
+ await vi.waitFor(() =>
+ expect(records).toEqual([
+ expect.objectContaining({
+ event: expect.objectContaining({ id: creation.id }),
+ delivery: "seen",
+ }),
+ ]),
+ );
+ } finally {
+ owner.dispose();
+ }
+
+ const restored = createRelaySession(
+ {
+ viewer: viewer.pubkey,
+ relayAuthor: relay.pubkey,
+ media: () => undefined,
+ query: async () => [],
+ writer: { kinds: [9007], sign, publish },
+ },
+ { outboxStorage: storage },
+ );
+ try {
+ await vi.waitFor(() =>
+ expect(restored.session.channelCreation.snapshot()).toEqual(input),
+ );
+ const retry = restored.session.channelCreation.create(input);
+ await flush();
+ expect(sign).not.toHaveBeenCalled();
+ expect(publish).not.toHaveBeenCalled();
+ restored.dispose();
+ await expect(retry).rejects.toThrow(/connection changed/);
+ } finally {
+ restored.dispose();
+ }
+});
+
+it("retries a seen creation after a real roster read error", async () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const id = "11111111-1111-4111-8111-111111111111";
+ const creation = signed(viewer, {
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Release notes"],
+ ["visibility", "open"],
+ ["channel_type", "stream"],
+ ],
+ });
+ const input = { name: "Release notes", visibility: "open" as const };
+ let records: readonly OutgoingEvent[] = [
+ { event: creation, signed: creation, delivery: "seen" },
+ ];
+ const sign = vi.fn(async () => creation);
+ const publish = vi.fn(async () => {});
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ const owner = createRelaySession(
+ { ...wire.transport, writer: { kinds: [9007], sign, publish } },
+ {
+ outboxStorage: {
+ load: () => structuredClone(records),
+ save: (next) => {
+ records = structuredClone(next);
+ },
+ },
+ },
+ );
+ try {
+ await vi.waitFor(() =>
+ expect(owner.session.channelCreation.snapshot()).toEqual(input),
+ );
+ owner.session.channels.ensureList();
+ wire.next().fail(new Error("roster read failed"));
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("error"),
+ );
+ let settled = false;
+ const retry = owner.session.channelCreation.create(input).finally(() => {
+ settled = true;
+ });
+ await vi.waitFor(() => expect(wire.pending.length).toBeGreaterThan(0));
+ expect(settled).toBe(false);
+ const publicMetadata = signed(relay, {
+ kind: 39000,
+ content: JSON.stringify({ name: "Release notes" }),
+ tags: [["d", id], ["name", "Release notes"], ["public"]],
+ });
+ wire.next().respond([roster(relay, id, [viewer.pubkey]), publicMetadata]);
+ await expect(retry).resolves.toBe(id);
+ expect(records).toHaveLength(0);
+ expect(owner.session.channelCreation.snapshot()).toBeUndefined();
+ expect(sign).not.toHaveBeenCalled();
+ expect(publish).not.toHaveBeenCalled();
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("removes a successfully refreshed channel creation from durable recovery", async () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let records: readonly OutgoingEvent[] = [];
+ const owner = createRelaySession(
+ {
+ ...wire.transport,
+ writer: {
+ kinds: [9, 9000, 9007],
+ sign: async (template) => signed(viewer, template),
+ publish: async () => {},
+ },
+ },
+ {
+ outboxStorage: {
+ load: () => structuredClone(records),
+ save: (next) => {
+ records = structuredClone(next);
+ },
+ },
+ },
+ );
+ try {
+ const creating = owner.session.channelCreation.create({
+ name: "Release notes",
+ visibility: "open",
+ });
+ await vi.waitFor(() =>
+ expect(records.some(({ event }) => event.kind === 9007)).toBe(true),
+ );
+ const id = records
+ .find(({ event }) => event.kind === 9007)
+ ?.event.tags.find(([name]) => name === "h")?.[1];
+ expect(id).toBeTypeOf("string");
+ const publicMetadata = signed(relay, {
+ kind: 39000,
+ content: JSON.stringify({ name: "Release notes" }),
+ tags: [["d", id ?? ""], ["name", "Release notes"], ["public"]],
+ });
+ await vi.waitFor(() => expect(wire.pending).toHaveLength(2));
+ for (const request of [wire.next(), wire.next()])
+ request.respond([publicMetadata]);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.get?.(id ?? "")?.readOnly).toBe(true),
+ );
+ let resolved = false;
+ void creating.then(() => {
+ resolved = true;
+ });
+ await flush();
+ expect(resolved).toBe(false);
+ expect(records.some(({ event }) => event.kind === 9007)).toBe(true);
+
+ owner.session.channels.refreshList?.();
+ await vi.waitFor(() => expect(wire.pending.length).toBeGreaterThan(0));
+ wire
+ .next()
+ .respond([roster(relay, id ?? "", [viewer.pubkey]), publicMetadata]);
+ await expect(creating).resolves.toBe(id);
+ await vi.waitFor(() =>
+ expect(records.some(({ event }) => event.kind === 9007)).toBe(false),
+ );
+ expect(owner.session.channelCreation.snapshot()).toBeUndefined();
+ } finally {
+ owner.dispose();
+ }
+});
+
it.each([true, false])(
"confirms an exact own creation without admitting a channel missing its roster (receipt: %s)",
async (found) => {
@@ -86,7 +480,13 @@ it.each([true, false])(
type RelaySessionRead = ReturnType<
typeof createRelaySession
>["session"]["read"];
-function setup(channelCreation = true) {
+function setup(
+ channelCreation = true,
+ channelList: ReturnType = {
+ status: "ready",
+ channels: [],
+ },
+) {
let items: readonly OutgoingEvent[] = [{ event, delivery: "accepted" }];
const listeners = new Set<() => void>();
const receipts = {
@@ -111,7 +511,9 @@ function setup(channelCreation = true) {
dismiss: vi.fn(async () => {}),
};
const channels = {
- list: () => ({ status: "ready", channels: [] }),
+ list: () => channelList,
+ subscribeList: () => () => {},
+ refreshList: vi.fn(),
} as unknown as ChannelQueries;
const reader = { read: vi.fn(async () => [event]) };
const controller = new AbortController();
@@ -124,6 +526,7 @@ function setup(channelCreation = true) {
receipts,
),
outbox,
+ channels,
reader,
listeners,
controller,
@@ -138,6 +541,18 @@ it("confirms completed journal receipts after they leave the pending outbox", as
expect(test.reader.read).not.toHaveBeenCalled();
expect(test.listeners.size).toBe(0);
});
+it("accepts already-applied creator membership without a refresh notification", async () => {
+ const id = "11111111-1111-4111-8111-111111111111";
+ const member = "a".repeat(64);
+ const test = setup(true, {
+ status: "ready",
+ channels: [{ id, name: "Release notes", members: [member] }],
+ });
+ await expect(
+ test.service.refresh(id, { member }, false),
+ ).resolves.toBeUndefined();
+ expect(test.channels.refreshList).toHaveBeenCalledOnce();
+});
it("retries the same unknown event and confirms restored receipts through verified reads", async () => {
const test = setup();
test.setItems([{ event, delivery: "unknown" }]);
@@ -208,6 +623,62 @@ it("starts a standalone session using existing private-channel creation without
expect(test.outbox.send).toHaveBeenCalledTimes(2);
});
+it("creates an ordinary stream with explicit visibility and optional description", () => {
+ const test = setup();
+ const id = "11111111-1111-4111-8111-111111111111";
+ test.service.createChannel(id, " Release notes ", "open", " Updates ");
+ expect(test.outbox.send).toHaveBeenCalledWith({
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Release notes"],
+ ["visibility", "open"],
+ ["channel_type", "stream"],
+ ["about", "Updates"],
+ ],
+ });
+ test.service.createChannel(id, "Private", "private");
+ expect(test.outbox.send).toHaveBeenLastCalledWith({
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Private"],
+ ["visibility", "private"],
+ ["channel_type", "stream"],
+ ],
+ });
+ test.service.createChannel(id, "Standup", "open", undefined, 604800);
+ expect(test.outbox.send).toHaveBeenLastCalledWith({
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", "Standup"],
+ ["visibility", "open"],
+ ["channel_type", "stream"],
+ ["ttl", "604800"],
+ ],
+ });
+ for (const description of [
+ SESSION_CHANNEL_DESCRIPTION,
+ "Buzz session (notes)",
+ ]) {
+ expect(() =>
+ test.service.createChannel(id, "Not a session", "private", description),
+ ).toThrow(/different channel description/);
+ }
+ expect(test.outbox.send).toHaveBeenCalledTimes(3);
+ expect(() => test.service.createChannel(id, " ", "open")).toThrow(/name/);
+ expect(() =>
+ test.service.createChannel(id, "Work", "open", "x".repeat(1001)),
+ ).toThrow(/description/);
+ expect(() =>
+ test.service.createChannel(id, "Work", "open", undefined, 0),
+ ).toThrow(/duration/);
+});
+
it("recovers a lost normal-channel creation acknowledgment only with its exact verified event", async () => {
const test = setup();
const creation = signed(keypair(), {
diff --git a/src/features/relay/work-sessions.ts b/src/features/relay/work-sessions.ts
index 29d65237e..dd72b99c9 100644
--- a/src/features/relay/work-sessions.ts
+++ b/src/features/relay/work-sessions.ts
@@ -3,6 +3,8 @@ import type { Outbox } from "./outbox";
import type { ChannelQueries } from "./contracts";
import type { RelayReader } from "./reader";
+export const DEFAULT_TEMPORARY_CHANNEL_TTL_SECONDS = 7 * 24 * 60 * 60;
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** Work-session commands use the same durable outbox and connection lifetime. */
@@ -20,7 +22,7 @@ export function createWorkSessions(
function writer() {
if (signal.aborted || !available || !outbox)
throw new Error(
- "This community does not support saved sessions yet. Your draft is kept here.",
+ "This community does not support channel creation yet. Your draft is kept here.",
);
return outbox;
}
@@ -132,9 +134,9 @@ export function createWorkSessions(
),
15000,
);
- const inspect = () => {
+ const inspect = (reportError = true) => {
const list = channels.list();
- if (list.status === "error")
+ if (reportError && list.status === "error")
done(new Error(list.error ?? "Session membership could not load."));
else if (
list.status === "ready" &&
@@ -156,6 +158,7 @@ export function createWorkSessions(
unsubscribe = channels.subscribeList(inspect);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
+ else inspect(false); // An earlier roster error does not decide this retry.
});
channels.refreshList?.();
await wait;
@@ -263,6 +266,43 @@ export function createWorkSessions(
available,
addAgents,
refreshMembership,
+ createChannel(
+ id: string,
+ title: string,
+ visibility: "open" | "private",
+ description?: string,
+ ttlSeconds?: number,
+ ) {
+ writer();
+ identifier(id);
+ const name = title.trim();
+ const about = description?.trim();
+ if (!name || [...name].length > 120)
+ throw new Error("Use a channel name between 1 and 120 characters.");
+ if (about && [...about].length > 1000)
+ throw new Error("Keep the channel description under 1,000 characters.");
+ if (about?.includes("Buzz session ("))
+ throw new Error("Choose a different channel description.");
+ if (
+ ttlSeconds !== undefined &&
+ (!Number.isInteger(ttlSeconds) ||
+ ttlSeconds <= 0 ||
+ ttlSeconds > 2_147_483_647)
+ )
+ throw new Error("Choose a valid temporary channel duration.");
+ return writer().send({
+ kind: 9007,
+ content: "",
+ tags: [
+ ["h", id],
+ ["name", name],
+ ["visibility", visibility],
+ ["channel_type", "stream"],
+ ...(about ? [["about", about]] : []),
+ ...(ttlSeconds ? [["ttl", String(ttlSeconds)]] : []),
+ ],
+ });
+ },
create(id: string, title: string, parentId?: string) {
writer();
identifier(id);
diff --git a/src/features/sessions/SessionPresentation.tsx b/src/features/sessions/SessionPresentation.tsx
index c03b9128e..1862d8511 100644
--- a/src/features/sessions/SessionPresentation.tsx
+++ b/src/features/sessions/SessionPresentation.tsx
@@ -1,5 +1,5 @@
import { PanelHeader } from "../../shared/design-system/ui/PanelHeader";
-import { HashIcon } from "../../shared/design-system/icons/index";
+import { channelIcon } from "../channels/channel-icon";
import type { ReactNode, RefObject } from "react";
import type { ChannelSummary } from "../relay/contracts";
import styles from "./Sessions.module.css";
@@ -40,14 +40,15 @@ export function SessionHeading({
headingRef,
children,
}: {
- channel: Pick;
+ channel: Pick;
parentName?: string | undefined;
headingRef?: RefObject | undefined;
children?: ReactNode;
}) {
+ const Icon = channelIcon(channel);
return (
}
+ icon={}
title={
{channel.name}
diff --git a/tests/browser/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs
index 3e0897a7d..d102b69f4 100644
--- a/tests/browser/navigation-sidebar.spec.mjs
+++ b/tests/browser/navigation-sidebar.spec.mjs
@@ -1,5 +1,6 @@
import { test, expect } from "./fixture.mjs";
import { open } from "./timeline.mjs";
+import { expectPhosphor } from "./phosphor.mjs";
const button = (page, name) => page.getByRole("button", { name, exact: true });
test.use({
largeSidebar: true,
@@ -198,6 +199,7 @@ sessionSidebar(
await expect(parent).toBeVisible();
await expect(child).toBeVisible();
await expect(regular).toBeVisible();
+ await expectPhosphor(regular.locator("svg").first(), "hash");
const regularIconX = await centerX(regular.locator("svg").first());
const parentIconX = await centerX(disclosure.locator("svg:visible"));
expect(parentIconX).toBeCloseTo(regularIconX, 0);
@@ -212,6 +214,15 @@ sessionSidebar(
"xpath=ancestor::*[@data-channel-sidebar-row]",
);
const more = page.getByRole("button", { name: /More options for/ }).first();
+ await expect(more).toHaveAttribute("data-icon-shape", "round");
+ const [parentSurfaceBox, moreBox] = await Promise.all([
+ parentSurface.boundingBox(),
+ more.boundingBox(),
+ ]);
+ expect(parentSurfaceBox.x + parentSurfaceBox.width).toBeCloseTo(
+ moreBox.x + moreBox.width,
+ 0,
+ );
expect(
await more.evaluate(
(action, row) => row.contains(action),
@@ -232,6 +243,115 @@ sessionSidebar(
const draft = page.getByRole("button", { name: /New session draft in/ });
await expect(draft).toBeVisible();
expect(await x(label(draft))).toBeCloseTo(await x(label(regular)), 0);
+
+ await child.click();
+ await expectPhosphor(
+ page
+ .getByRole("article", { name: "Conversation" })
+ .locator(".panel-header-title > svg"),
+ "lock",
+ );
+ },
+);
+
+sessionSidebar(
+ "session rows use pill hovers and Channels opens the shared creation dialog",
+ async ({ page, app }) => {
+ await page.goto(app.origin);
+ await button(page, "Messages").first().click();
+ const child = page.locator('[data-channel-id="alpha"]');
+ await expect(child).toBeVisible();
+ await child.hover();
+ await expect(child).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+
+ const channels = page
+ .getByRole("navigation", { name: "Subscribed channels" })
+ .locator("details")
+ .filter({ has: page.locator("summary", { hasText: /^Channels$/ }) });
+ const summary = channels.locator("summary");
+ const create = page.getByRole("button", { name: "Create channel" });
+ const createContainer = create.locator("..");
+ await expect(createContainer).toHaveCSS("opacity", "0");
+ await summary.hover();
+ await expect(createContainer).toHaveCSS("opacity", "1");
+ await expect(create).toHaveAttribute("data-icon-shape", "round");
+ const [summaryBox, createBox] = await Promise.all([
+ summary.boundingBox(),
+ create.boundingBox(),
+ ]);
+ expect(summaryBox.x + summaryBox.width).toBeCloseTo(
+ createBox.x + createBox.width,
+ 0,
+ );
+ await create.click();
+
+ const dialog = page.getByRole("dialog", { name: "Create a channel" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole("textbox", { name: "Name" })).toBeFocused();
+ await expect(dialog.getByRole("radio", { name: /Ongoing/ })).toBeChecked();
+ await expect(
+ dialog.getByRole("switch", { name: "Private" }),
+ ).not.toBeChecked();
+ await expect(
+ dialog.getByRole("textbox", { name: "Description" }),
+ ).toHaveCount(0);
+ const addDescription = dialog.getByRole("button", {
+ name: "Add a description",
+ });
+ const formTypography = await Promise.all(
+ [
+ addDescription,
+ dialog.getByText("Name", { exact: true }),
+ dialog.getByText("Private", { exact: true }),
+ dialog.getByText("Ongoing", { exact: true }),
+ ].map((element) =>
+ element.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { color: style.color, fontSize: style.fontSize };
+ }),
+ ),
+ );
+ const placeholderColor = await dialog
+ .getByRole("textbox", { name: "Name" })
+ .evaluate((node) => getComputedStyle(node, "::placeholder").color);
+ const tertiaryColor = await page.evaluate(() => {
+ const probe = document.createElement("span");
+ probe.style.color = "var(--text-tertiary)";
+ document.body.append(probe);
+ const color = getComputedStyle(probe).color;
+ probe.remove();
+ return color;
+ });
+ expect(formTypography[0].color).toBe(formTypography[1].color);
+ expect(placeholderColor).toBe(tertiaryColor);
+ expect(formTypography[2].fontSize).toBe(formTypography[3].fontSize);
+ await expect(addDescription).toHaveCSS("border-radius", "0px");
+ await addDescription.hover();
+ await expect(addDescription).toHaveCSS("text-decoration-line", "underline");
+ await expect(addDescription).toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
+ await addDescription.click();
+ await expect(
+ dialog.getByRole("textbox", { name: "Description" }),
+ ).toBeFocused();
+ await dialog.getByRole("radio", { name: /Temporary/ }).click();
+ await expect(
+ dialog.getByRole("radio", { name: /Temporary/ }),
+ ).toBeChecked();
+ await dialog.getByRole("switch", { name: "Private" }).click();
+ await expect(dialog.getByRole("switch", { name: "Private" })).toBeChecked();
+ await expect(dialog.getByRole("button", { name: "Cancel" })).toHaveCount(0);
+ await dialog
+ .getByRole("button", { name: "Close channel creation" })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ await expect(create).toBeFocused();
+ await page
+ .getByRole("article", { name: "Conversation" })
+ .hover({ position: { x: 20, y: 20 } });
+ await expect(createContainer).toHaveCSS("opacity", "0");
},
);