From 4a4ebf0d35d2913aaf212345ee5c92fae7aaa349 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 02:19:03 +1100
Subject: [PATCH 01/28] feat(communities): open new members in a welcome
channel and let admins set its welcome
Messages opens the community's welcome channel (the stream whose
relay-signed 39000 carries `landing`) after an explicit request and the
saved selection, ahead of the auto-join fallback. Its timeline starts
with a welcome card: the community name and its custom message or the
default one. While the channel list loads, Messages shows an opening
state instead of a blank view; with no channels, an empty state offers
Try again, which reconnects so the relay can retry enrollment. A
`welcome` tag without `landing` is ignored.
Owners and admins get Community admin -> Welcome when the relay's NIP-11
advertises `buzz-landing-channel`. The card reads the designation from
NIP-11 and the message with a strong 39000 read, then saves the full
state as one 9002 `[h, landing=true, welcome=text|""]` through the
channel-lifecycle route, confirmed by the existing strong reread. Text
mirrors the relay: 1-1000 scalar values, no controls except newline,
CRLF normalized and trimmed. Relay `invalid:` reasons show with the
draft kept. Both hosts admit only that exact command shape.
Protected-channel UI disabling is deferred; relay refusals surface
through the existing lifecycle errors.
Co-Authored-By: Claude Opus 5.5
Signed-off-by: Matt Toohey
---
browser-host/relay-broker-api.test.mjs | 67 ++-
browser-host/relay-broker.mjs | 2 +
docs/buzz-help.md | 10 +
docs/channels.md | 42 +-
docs/communities.md | 40 +-
src-tauri/src/relay/channel_writes.rs | 74 ++++
src/bundled/channels/ChannelBody.tsx | 20 +
.../channels/ChannelLifecycleActions.test.tsx | 2 +
.../channels/ChannelLifecycleMenu.test.tsx | 2 +
.../channels/ChannelSettingsPanel.test.tsx | 2 +
.../channels/ChannelsPage.tabs.test.tsx | 118 ++++-
src/bundled/channels/ChannelsPage.tsx | 45 +-
src/bundled/moderation/README.md | 7 +
.../moderation/WelcomeSettings.module.css | 18 +
.../moderation/WelcomeSettings.test.tsx | 248 +++++++++++
src/bundled/moderation/WelcomeSettings.tsx | 413 ++++++++++++++++++
src/bundled/moderation/index.tsx | 79 +++-
src/bundled/moderation/registration.test.ts | 70 +++
src/bundled/moderation/welcome.ts | 109 +++++
.../communities/WelcomeCard.module.css | 17 +
src/features/communities/WelcomeCard.test.tsx | 43 ++
src/features/communities/WelcomeCard.tsx | 63 +++
src/features/communities/api.ts | 4 +
src/features/communities/native-api.test.ts | 22 +
src/features/communities/native-api.ts | 2 +
src/features/messages/ChannelTimeline.tsx | 13 +
src/features/messages/Messages.module.css | 6 +
.../relay/channel-lifecycle-protocol.ts | 41 +-
src/features/relay/channel-lifecycle.test.ts | 262 +++++++++++
src/features/relay/channel-lifecycle.ts | 184 ++++++--
src/features/relay/contracts.ts | 4 +
src/features/relay/discovery-landing.test.ts | 48 ++
src/features/relay/discovery.ts | 2 +
src/features/relay/landing.test.ts | 91 ++++
src/features/relay/landing.ts | 62 +++
src/features/relay/store.test.ts | 25 ++
src/features/relay/store.ts | 2 +
37 files changed, 2189 insertions(+), 70 deletions(-)
create mode 100644 src/bundled/moderation/WelcomeSettings.module.css
create mode 100644 src/bundled/moderation/WelcomeSettings.test.tsx
create mode 100644 src/bundled/moderation/WelcomeSettings.tsx
create mode 100644 src/bundled/moderation/welcome.ts
create mode 100644 src/features/communities/WelcomeCard.module.css
create mode 100644 src/features/communities/WelcomeCard.test.tsx
create mode 100644 src/features/communities/WelcomeCard.tsx
create mode 100644 src/features/relay/discovery-landing.test.ts
create mode 100644 src/features/relay/landing.test.ts
create mode 100644 src/features/relay/landing.ts
diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index fee5cdbb27..c8562a95f9 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -3397,6 +3397,33 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
["p", transport.viewer],
],
},
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "false"],
+ ["welcome", ""],
+ ],
+ },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "a\tb"],
+ ],
+ },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["name", "rename"],
+ ],
+ },
{ ...template, content: "extra" },
{
...template,
@@ -3463,7 +3490,22 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
expect(flagged).toMatchObject(flag);
await transport.channelLifecycle.publish(flagged, signal);
}
- expect(h.publications).toHaveLength(4);
+ const landing = {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "Say hi\nhere"],
+ ],
+ };
+ expect((await h.post("sign", landing)).status).toBe(400);
+ expect((await h.post("channel-details-sign", landing)).status).toBe(400);
+ const designated = await transport.channelLifecycle.sign(landing, signal);
+ expect(verifyEvent(designated)).toBe(true);
+ expect(designated).toMatchObject(landing);
+ await transport.channelLifecycle.publish(designated, signal);
+ expect(h.publications).toHaveLength(5);
} finally {
live?.dispose();
await h.close();
@@ -4151,6 +4193,29 @@ test.each([[], [1, 42], [1, 42, 43]])(
},
);
+test("join discovery projects welcome-channel support and designation", async () => {
+ const landing = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11";
+ const h = await harness((call) =>
+ Response.json(
+ call.url === fixtureRelayUrl
+ ? {
+ name: "Community",
+ supported_extensions: ["buzz-landing-channel"],
+ landing_channel: landing,
+ }
+ : { policy: null },
+ ),
+ );
+ try {
+ expect(await (await h.get("info")).json()).toMatchObject({
+ landingChannels: true,
+ landingChannel: landing,
+ });
+ } finally {
+ await h.close();
+ }
+});
+
test("Builderlab HTTP accepts full agent instructions and isolates the fixed native routes", async () => {
let openLogin;
const opened = new Promise((resolve) => {
diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs
index b49631ad19..bcf8aef59e 100644
--- a/browser-host/relay-broker.mjs
+++ b/browser-host/relay-broker.mjs
@@ -26,6 +26,7 @@ import {
} from "../src/features/repos/git.ts";
import { validateMemberAdministrationTemplate } from "../src/features/channel-members/administration-protocol.ts";
import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts";
+import { landingInfo } from "../src/features/relay/landing.ts";
import { validateDetailsTemplate } from "../src/features/relay/channel-details-protocol.ts";
import { validateArchiveRequestTemplate } from "../src/features/relay/identity-archive-protocol.ts";
import {
@@ -1319,6 +1320,7 @@ export function relayBrokerPlugin({
name: info.name,
icon: info.icon,
policy: policy ?? null,
+ ...landingInfo(info),
...(gifSearchPath
? {
supported_extensions: ["buzz-gif"],
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 31ec587782..2a36cd827a 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -462,6 +462,16 @@ revokes all of them, and a pairing unused for 30 days expires.
- **You don't have access to this community**: when you're signed in through
the community's server, it names the account it refused and offers
**Switch account**. **Check again** once an admin has added you.
+- **Welcome channel**: on relays that support it, a community can choose a
+ public channel that new members join and open first. It starts with a
+ welcome card for everyone, showing the community's message or a default one.
+ The card isn't a message: it can't be replied to, deleted or dismissed. While
+ a new member's channels arrive, Messages says it is opening them. If none
+ arrive, **Try again** reconnects. Owners and admins choose the channel and edit
+ the message, up to 1,000 characters of plain text, in **Settings → Community
+ admin → Welcome**. That card appears only when the relay supports a welcome
+ channel. People already in the community keep their channels when the welcome
+ channel changes.
- **Finding channels**: search's **Channels** tab lists the community's open
channels you can join, with their member counts; typing also matches channels
you're in. Opening an unjoined channel shows a preview with **Join**. Private
diff --git a/docs/channels.md b/docs/channels.md
index bc7a96772e..c3d9546f09 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -578,6 +578,17 @@ ownership; the relay accepts the flag only from community owners and admins) and
confirms the result from the republished `39000`: the relay advertises the flag
as a bare `auto_join` tag only while it is in effect, so success is that tag's
presence or absence, not the written value.
+The community **Welcome** settings card ([communities](communities.md#welcome-channel))
+uses the same route for the welcome channel. Each save is one `9002` carrying the
+full state, `landing=true` plus `welcome=`, or an empty `welcome` for the
+default message; the relay accepts a repeat of the current state. Before signing
+and again before publishing, the lifecycle owner requires fresh relay metadata
+for an active, open stream channel without an expiry. It confirms the save with a
+strong reread of the target's `39000`: the relay advertises a bare `landing` tag
+there while the channel is designated, plus a `welcome` tag when the message is
+custom. A `welcome` tag without `landing` is ignored. The relay's `invalid:`
+reason is shown with the draft kept. When the result can't be confirmed, the
+card asks the admin to save again.
A published lifecycle command keeps its bounded, fresh, strong confirmation
when access loss retires access-dependent work. Retired confirmation reads
are reread; caller cancellation, session clear/disposal and the original deadline
@@ -585,11 +596,13 @@ still abort its signal. A rejected publication or definitive confirmation-read
failure still fails the command; an aborted read alone never confirms it. These signals
do not forcibly interrupt an in-flight native publication IPC.
Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive),
-`archived=false` (Unarchive) or `auto_join=true|false` (the switch); other values,
+`archived=false` (Unarchive) or `auto_join=true|false` (the switch), and the
+three-tag welcome shape with `landing=true` and a `welcome` of 1–1,000 characters
+(no control characters except line breaks) or empty; other values,
extra tags, cross-route commands and
altered or foreign signatures are rejected. Hosts without this capability display
-an unavailable notice. Native Unarchive and the auto-join switch require
-rebuilding/restarting the desktop
+an unavailable notice. Native Unarchive, the auto-join switch and the welcome
+command require rebuilding/restarting the desktop
binary; updating the frontend alone does not update the Rust validator. Rust
validator and production-IPC tests cover both values, but do not establish an
installed desktop Archive → Unarchive round trip against a live relay; that
@@ -1292,11 +1305,20 @@ concurrently with the real connection handshake. Saved groups, stars and channel
names are display data, not a confirmed preference mutation base. No cache means
the ordinary cold connection flow; unavailable/corrupt storage never grants access.
-With no saved selection, Messages opens the first channel the relay flags
-`auto_join` (its starter channel for new members), then the first non-Solo
-channel; an explicit empty route bypasses both. The relay adds people to its
-`auto_join` channels itself, and the resulting membership hints populate the
-sidebar live:
+With no saved selection, Messages opens the community's welcome channel if the
+viewer is in it, then the first channel the relay flags `auto_join` (its starter
+channel for new members), then the first non-Solo channel; an explicit empty
+route bypasses all three. The welcome channel is the stream channel whose
+relay-signed `39000` carries `landing`. Its timeline starts with a welcome card
+for every viewer, even with no messages: the community name and its custom
+message, or the default one. The card is not a message, so it has no unread
+state, actions or dismissal. While the channel list or its metadata is still
+loading, Messages shows **Opening your channels…** instead of the blank
+selection view. If the list loads with no channels, an empty state offers
+**Try again**, which reconnects so the relay can retry adding the person. The
+client never joins the welcome channel for them. The relay adds people to its
+welcome and `auto_join` channels itself, welcome channel first, and the
+resulting membership hints populate the sidebar live:
- A community that requires relay membership adds a member once, when they join
its roster: an invite claim that answers `joined`, or an admin's kind `9030`
@@ -1310,7 +1332,9 @@ sidebar live:
Either way a later leave stands: the roster path does not run again for an
existing member, and on the open path a leave or removal keeps a membership
record, so the relay does not add the person back. The client only reads the
-flag; it never sends a join on a member's behalf.
+flag; it never sends a join on a member's behalf. The welcome channel is joined
+only at a person's first entry, on either path: moving the designation never adds
+anyone who already entered.
A cached roster can display previously downloaded, reverified history for up to
24 hours. It cannot authorize head/history reads, unread evidence, typing or
diff --git a/docs/communities.md b/docs/communities.md
index 3c5f831684..ce52951ffb 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -65,12 +65,16 @@ an address typed on the welcome screen that the restore did not admit opens
this dialog, prefilled, as the app opens. Builderlab's Settings **Create
community** is the third: through the `communityJoin` capability it opens this
dialog on the community just created, marked submitted, so it connects at once
-as first run's **Create a community** does, without Continue. The relay adds a member newly admitted
-by an invite claim to the community's starter channels, so they are already in
-the sidebar and Messages opens the first of them. An open community needs no
+as first run's **Create a community** does, without Continue. The relay adds a
+member newly admitted by an invite claim to the community's
+[welcome channel](#welcome-channel) and starter channels, so they are already
+in the sidebar. Messages opens the welcome channel, or else the first starter
+channel. An open community needs no
claim: there the relay adds each person, though not agents, to the starter
-channels they have never been in each time they connect, so those channels
-appear a moment later. See [local-first launch](channels.md#local-first-launch).
+channels they have never been in each time they connect, and to the welcome
+channel when they first connect. Those channels appear a moment later, while
+Messages shows that it is opening the person's channels. See
+[local-first launch](channels.md#local-first-launch).
A brand-new community has no channels. When its owner opens Messages there,
having joined no channel, the client reads at most 20 stream or forum metadata
@@ -161,6 +165,32 @@ empty state there. Try drafting in A, switching to B, then returning to A.
Selected channels, drafts and reading offsets are partitioned by the canonical
community origin and viewer; channel IDs alone are not sufficient keys.
+## Welcome channel
+
+A relay that advertises the `buzz-landing-channel` NIP-11 extension keeps one
+welcome channel per community. The relay adds each newly entering human to it
+first, ahead of the starter channels. Messages opens it when there is no saved
+selection, and its timeline starts with a welcome card (see
+[local-first launch](channels.md#local-first-launch)).
+
+Owners and admins set it under **Community admin → Welcome**. The card appears
+only when the relay advertises the extension and the relay-signed roster gives
+the viewer an owner or admin role. A failed support read hides the card. Each
+time the card opens, it reads the current designation from a fresh NIP-11
+document, then reads that channel's message with a strong metadata read. If
+nothing is designated yet, the channel field starts empty and must be chosen
+before Save. The picker lists public, unarchived stream channels. The message
+editor starts with the custom text or the default, and checks the relay's rules
+as the admin types: 1–1,000 characters, and no control characters other than
+line breaks. Line endings are normalized and the message is trimmed before
+sending. A message matching the default is saved as the default rather than as
+custom text. **Restore default message** changes only the draft. A preview shows
+the card as members will see it. Save sends the whole state and confirms it as
+described in [channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
+its reason and the draft is kept. Archive, Delete, Make private and expiry are
+not disabled for the welcome channel yet; the relay refuses them, and that
+refusal is shown like any other lifecycle error.
+
## Import from the previous desktop app
After native startup, Buzz offers the previous desktop app's saved communities
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index 24ec1aa51f..19a0dd6e3b 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -83,7 +83,21 @@ fn details_labels(labels: &[Vec]) -> bool {
}
}
}
+/// The welcome message is `""` (restore the default) or 1–1000 scalar values
+/// of plain text, matching the relay's rules.
+fn welcome(text: &str) -> bool {
+ text.is_empty()
+ || (!text.trim().is_empty()
+ && text.chars().count() <= 1000
+ && text.chars().all(|c| c == '\n' || !c.is_control()))
+}
fn lifecycle(event: &EventTemplate) -> bool {
+ // Owners/admins set the community's welcome channel with its full state.
+ if event.kind == 9002 && common(event) && event.tags.len() == 3 {
+ return tag(event, 0, "h").is_some_and(uuid)
+ && tag(event, 1, "landing") == Some("true")
+ && tag(event, 2, "welcome").is_some_and(welcome);
+ }
if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012)
|| !common(event)
|| event.tags.len() != if event.kind == 9002 { 2 } else { 1 }
@@ -486,6 +500,66 @@ mod boundary_tests {
}
}
#[test]
+ fn lifecycle_accepts_only_the_exact_landing_command() {
+ let channel = uuid::Uuid::nil().to_string();
+ let event = serde_json::json!({
+ "kind": 9002,
+ "created_at": 1700000010,
+ "content": "",
+ "tags": [["h", channel], ["landing", "true"], ["welcome", ""]]
+ });
+ let longest = "é".repeat(1000);
+ for text in ["", "Hi all!\nIntroduce yourself.", longest.as_str()] {
+ let mut command = event.clone();
+ command["tags"][2][1] = serde_json::json!(text);
+ assert!(validate("channel-lifecycle", &command, None).is_ok());
+ assert!(validate("channel-details", &command, None).is_err());
+ }
+ for text in [
+ serde_json::json!(" \n "),
+ serde_json::json!("a".repeat(1001)),
+ serde_json::json!("tab\there"),
+ serde_json::json!("line\r\nbreak"),
+ serde_json::json!("bell\u{7}"),
+ serde_json::json!("c1\u{85}"),
+ Value::Null,
+ serde_json::json!(1),
+ ] {
+ let mut command = event.clone();
+ command["tags"][2][1] = text;
+ assert!(validate("channel-lifecycle", &command, None).is_err());
+ }
+ for tags in [
+ serde_json::json!([["h", channel], ["landing", "false"], ["welcome", ""]]),
+ serde_json::json!([["h", channel], ["landing", "true"]]),
+ serde_json::json!([["h", channel], ["welcome", ""]]),
+ serde_json::json!([["h", channel], ["welcome", ""], ["landing", "true"]]),
+ serde_json::json!([["h", channel], ["landing", "true"], ["welcome"]]),
+ serde_json::json!([["h", channel], ["landing", "true"], ["archived", "true"]]),
+ serde_json::json!([
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", "", "extra"]
+ ]),
+ serde_json::json!([
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome", ""]
+ ]),
+ ] {
+ let mut command = event.clone();
+ command["tags"] = tags;
+ assert!(validate("channel-lifecycle", &command, None).is_err());
+ }
+ let mut delete = event.clone();
+ delete["kind"] = serde_json::json!(9008);
+ assert!(validate("channel-lifecycle", &delete, None).is_err());
+ let mut content = event;
+ content["content"] = serde_json::json!("x");
+ assert!(validate("channel-lifecycle", &content, None).is_err());
+ }
+ #[test]
fn lifecycle_accepts_only_exact_archive_and_auto_join_values() {
for flag in ["archived", "auto_join"] {
lifecycle_accepts_only_exact_flag_values(flag);
diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx
index 593da82cc2..0b6dba7cdc 100644
--- a/src/bundled/channels/ChannelBody.tsx
+++ b/src/bundled/channels/ChannelBody.tsx
@@ -9,6 +9,8 @@ import { useChannelWindow } from "../../features/relay/react";
import { useRecoveryOwns } from "../../features/relay/RecoveryNotice";
import { headRecovering } from "../../features/relay/live-recovery";
import { clientMetrics } from "../../features/developer/client-metrics";
+import { CommunityWelcomeCard } from "../../features/communities/WelcomeCard";
+import type { CommunityReader } from "../../features/communities/service";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Channels.module.css";
@@ -23,8 +25,17 @@ export const ChannelBody = memo(function ChannelBody({
onOpenThread,
navigation,
onContentReady,
+ landing,
+ welcomeMessage,
+ communities,
}: {
onContentReady?(channelId: string): void;
+ /** The community's welcome channel: its timeline starts with a welcome. */
+ landing?: boolean | undefined;
+ /** Custom welcome text; absent shows the default. */
+ welcomeMessage?: string | undefined;
+ /** Names the community in the welcome. */
+ communities?: CommunityReader | undefined;
cached: boolean;
intro?: ReactNode;
viewer?: string | undefined;
@@ -139,6 +150,15 @@ export const ChannelBody = memo(function ChannelBody({
navigation={navigation}
chatPresentation={solo}
historyControl={solo ? "scroll" : "button"}
+ start={
+ landing && (
+
+ )
+ }
/>
);
});
diff --git a/src/bundled/channels/ChannelLifecycleActions.test.tsx b/src/bundled/channels/ChannelLifecycleActions.test.tsx
index 731c7ddab0..6f95be5ffa 100644
--- a/src/bundled/channels/ChannelLifecycleActions.test.tsx
+++ b/src/bundled/channels/ChannelLifecycleActions.test.tsx
@@ -28,6 +28,8 @@ function session(available = true) {
subscribe: () => () => {},
observeRemoval: () => () => {},
refreshVisibility: async () => {},
+ readLanding: vi.fn(),
+ setLanding: vi.fn(),
} satisfies ChannelLifecycleCapability;
return {
viewer,
diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx
index 3b3043f8a6..678b7a2c10 100644
--- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx
+++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx
@@ -43,6 +43,8 @@ function capability() {
subscribe: () => () => {},
observeRemoval: () => () => {},
refreshVisibility: async () => {},
+ readLanding: vi.fn(),
+ setLanding: vi.fn(),
} satisfies ChannelLifecycleCapability;
}
const viewer = "a".repeat(64),
diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx
index a413c8cae5..5dcc3c705d 100644
--- a/src/bundled/channels/ChannelSettingsPanel.test.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx
@@ -310,6 +310,8 @@ function lifecycle(available = true) {
subscribe: () => () => {},
observeRemoval: () => () => {},
refreshVisibility: async () => {},
+ readLanding: vi.fn(),
+ setLanding: vi.fn(),
} satisfies ChannelLifecycleCapability;
}
function membership(role: Role | undefined) {
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 619dc8dd4d..2663240e9b 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -21,7 +21,10 @@ import type {
} from "../../features/panels/service";
import type { PageNavigation } from "../../features/navigation/service";
import type { OpenTarget } from "../../features/navigation/targets";
-import { soloDraftTarget } from "../../features/channel-navigation/routes";
+import {
+ conversationTarget,
+ soloDraftTarget,
+} from "../../features/channel-navigation/routes";
import {
createRelaySession,
type RelaySession,
@@ -417,6 +420,119 @@ it("opens the community's auto-join channel first when nothing is saved", async
await screen.findByRole("textbox", { name: "Message #Beta" });
});
+it("opens the community's welcome channel before its auto-join channel and starts it with the welcome", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [
+ ["t", "stream"],
+ ["auto_join"],
+ ]),
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ["welcome", "Say hi\nand introduce yourself."],
+ ]),
+ ],
+ viewer,
+ authority,
+ );
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ const welcome = await screen.findByRole("region", { name: "Welcome" });
+ expect(welcome).toHaveTextContent("Say hi and introduce yourself.");
+ // The welcome is not a message and replaces the empty-conversation notice.
+ expect(welcome.closest("[data-message-id]")).toBeNull();
+ expect(screen.queryByText("No messages yet.")).toBeNull();
+});
+
+it("shows the default welcome only in the welcome channel", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ const { visit, scope } = workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [
+ ["t", "stream"],
+ // A stale message without the designation is ignored.
+ ["welcome", "Old"],
+ ]),
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ ],
+ viewer,
+ authority,
+ );
+ expect(
+ await screen.findByRole("region", { name: "Welcome" }),
+ ).toHaveTextContent("This is a place to connect with the community");
+ visit(conversationTarget(scope, "alpha"));
+ await screen.findByRole("textbox", { name: "Message #Alpha" });
+ expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull();
+ expect(screen.queryByText("Old")).toBeNull();
+});
+
+it("keeps a saved selection over the welcome channel", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ writeView(`https://relay.test:${viewer.pubkey}`, "selected-channel", "alpha");
+ workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]),
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ ],
+ viewer,
+ authority,
+ );
+ await screen.findByRole("textbox", { name: "Message #Alpha" });
+ expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull();
+});
+
+it("falls back when the viewer is not in the welcome channel", async () => {
+ const viewer = keypair(),
+ authority = keypair(),
+ other = keypair();
+ workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]),
+ roster(authority, "beta", [other.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["public"],
+ ["landing"],
+ ]),
+ ],
+ viewer,
+ authority,
+ );
+ await screen.findByRole("textbox", { name: "Message #Alpha" });
+});
+
+it("offers a reconnect instead of a blank view when the viewer has no channels", async () => {
+ const { owner } = workspace([]);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ expect(
+ await screen.findByRole("heading", {
+ name: "You’re not in any channels yet",
+ }),
+ ).toBeVisible();
+ expect(screen.queryByText("Select a channel to read it.")).toBeNull();
+ expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled();
+});
+
it("hands the community roster to Channel settings and follows the republished flag", async () => {
const viewer = keypair(),
authority = keypair();
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 2057bdb217..aa7160b53e 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -86,6 +86,7 @@ import {
LockIcon,
ChatCircleIcon,
GearIcon,
+ HashIcon,
} from "../../shared/design-system/icons/index";
import { channelIcon } from "../../features/channels/channel-icon";
import { communityRefusalTitle } from "../../features/federated-identity/CommunitySignIn";
@@ -574,7 +575,11 @@ function ChannelWorkspace({
// A computed choice stays put while its channel is listed, so a channel
// flagged or added later never swaps the one being read.
channels.find((channel) => channel.id === chosen.current) ??
- // A new member lands in the community's default channel when it has one.
+ // A new member lands in the community's welcome channel, else in its
+ // default channel when it has one.
+ channels.find(
+ (channel) => channel.landing && channel.channelType === "stream",
+ ) ??
channels.find(
(channel) => channel.autoJoin && channel.channelType !== "solo",
) ??
@@ -587,6 +592,17 @@ function ChannelWorkspace({
requestedDestination?.huddle || requestedDestination?.metadataPending
? undefined
: requestedDestination;
+ // With nothing requested, the default channel is still arriving rather than
+ // unchosen: a new member's welcome channel can follow the first list.
+ const defaulting = !emptyDestination && !requestedChannel && !current;
+ const opening =
+ defaulting &&
+ (list.status === "idle" || list.status === "loading" || metadataPending);
+ const noChannels =
+ defaulting &&
+ list.status === "ready" &&
+ !metadataPending &&
+ !channels.some((channel) => channel.channelType !== "solo");
useLayoutEffect(() => {
// An open draft resolves nothing; keep the choice for when it closes.
if (requestedDestination) chosen.current = requestedDestination.id;
@@ -2045,7 +2061,7 @@ function ChannelWorkspace({
}
tabIndex={-1}
ref={historyContainer}
- className={`relative flex min-h-0 flex-col${current ? " flex-1" : ""}`}
+ className={`relative flex min-h-0 flex-col${current || noChannels ? " flex-1" : ""}`}
>
{current ? (
+ ) : noChannels ? (
+
+ }
+ title="You’re not in any channels yet"
+ description="Channels you join will appear here. If you just joined this community, try again."
+ action={
+ relay.retry()}
+ >
+ Try again
+
+ }
+ />
+
) : (
{resolving
? "Checking conversation access…"
- : "Select a channel to read it."}
+ : opening
+ ? "Opening your channels…"
+ : "Select a channel to read it."}
)}
{current && channelNotice && (
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 1ab91e432c..1164794783 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -15,3 +15,10 @@ community where you are an owner or admin.
Direct addition by public identity is retained behind `DIRECT_MEMBER_ADD_ENABLED`
for a possible return, but is hidden in the shipped UI.
+
+## Welcome channel
+
+On relays that support it, **Settings → Community admin → Welcome** lets an
+owner or admin choose the public channel new members join and open first, and
+the message shown at the start of that channel. Saving moves the welcome to the
+chosen channel; people already in the community keep their channels.
diff --git a/src/bundled/moderation/WelcomeSettings.module.css b/src/bundled/moderation/WelcomeSettings.module.css
new file mode 100644
index 0000000000..8232ff9744
--- /dev/null
+++ b/src/bundled/moderation/WelcomeSettings.module.css
@@ -0,0 +1,18 @@
+.editor {
+ display: grid;
+ gap: var(--space-6);
+ min-width: 0;
+}
+.editor[data-placeholder] {
+ visibility: hidden;
+}
+.fields {
+ display: grid;
+ gap: var(--space-4);
+ min-width: 0;
+}
+.preview {
+ display: grid;
+ gap: var(--space-3);
+ min-width: 0;
+}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
new file mode 100644
index 0000000000..ca72aac5e3
--- /dev/null
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -0,0 +1,248 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ communityRequest,
+ type CommunityInfo,
+} from "../../features/communities/api";
+import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard";
+import {
+ ChannelLifecycleUnconfirmed,
+ type ChannelLanding,
+} from "../../features/relay/channel-lifecycle";
+import { PublishRejected } from "../../features/relay/outbox";
+import type { RelayData } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
+import { ToastProvider } from "../../shared/design-system/ui/Toast";
+import { WelcomeSettings } from "./WelcomeSettings";
+
+vi.mock("../../features/communities/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ communityRequest: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const owner = "0".repeat(64);
+const lobby = "11111111-1111-4111-8111-111111111111";
+const square = "22222222-2222-4222-8222-222222222222";
+const rows = [
+ { id: lobby, name: "Lobby", channelType: "stream" as const },
+ { id: square, name: "Town square", channelType: "stream" as const },
+ {
+ id: "33333333-3333-4333-8333-333333333333",
+ name: "Old news",
+ channelType: "stream" as const,
+ archived: true as const,
+ },
+ {
+ id: "44444444-4444-4444-8444-444444444444",
+ name: "Questions",
+ channelType: "forum" as const,
+ },
+];
+
+function setup({
+ info = { policy: null, landingChannels: true, landingChannel: lobby },
+ landing = { landing: true, welcome: "Hi there", name: "Lobby" },
+}: {
+ info?: CommunityInfo;
+ landing?: ChannelLanding | undefined;
+} = {}) {
+ const readLanding = vi.fn(async () => landing);
+ const setLanding = vi.fn(async () => {});
+ const session = {
+ channelLifecycle: { available: true, readLanding, setLanding },
+ channels: {
+ subscribeList: () => () => {},
+ subscribeDirectory: () => () => {},
+ openDirectory: () => rows,
+ browseDirectory: () => rows,
+ readDirectory: async () => ({ partial: false }),
+ directoryComplete: () => true,
+ },
+ } as unknown as RelaySession;
+ const snapshot = {
+ status: "ready",
+ generation: 1,
+ scope: `https://primary.example:${owner}`,
+ viewer: owner,
+ session,
+ };
+ const relay = {
+ snapshot: () => snapshot,
+ subscribe: () => () => {},
+ } as unknown as RelayData;
+ vi.mocked(communityRequest).mockResolvedValue(info);
+ render(
+ true}
+ />,
+ { wrapper: ToastProvider },
+ );
+ return { readLanding, setLanding, user: userEvent.setup() };
+}
+
+const channel = () => screen.getByRole("combobox", { name: "Welcome channel" });
+const messageField = () => screen.getByRole("textbox", { name: "Message" });
+
+it("opens on the designated channel and its message, read fresh", async () => {
+ const { readLanding } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ expect(messageField()).toHaveValue("Hi there");
+ expect(communityRequest).toHaveBeenCalledWith(
+ "https://primary.example",
+ "info",
+ undefined,
+ expect.any(AbortSignal),
+ );
+ expect(readLanding).toHaveBeenCalledWith(lobby, expect.any(AbortSignal));
+ expect(
+ screen.getByRole("region", { name: "Welcome to Primary" }),
+ ).toHaveTextContent("Hi there");
+ // Nothing changed yet, so there is nothing to save.
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+});
+
+it("moves the welcome to another public channel with its full state", async () => {
+ const { setLanding, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ await user.click(screen.getByRole("button", { name: "Browse channels" }));
+ // Only active public stream channels are offered.
+ expect(
+ (await screen.findAllByRole("option")).map((option) => option.textContent),
+ ).toEqual(["Lobby", "Town square"]);
+ await user.click(screen.getByRole("option", { name: "Town square" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ square,
+ "Hi there",
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+});
+
+it("requires a channel when none is designated and sends the default as empty", async () => {
+ const { readLanding, setLanding, user } = setup({
+ info: { policy: null, landingChannels: true },
+ });
+ await waitFor(() => expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE));
+ expect(channel()).toHaveValue("");
+ expect(readLanding).not.toHaveBeenCalled();
+ fireEvent.change(messageField(), {
+ target: { value: `${DEFAULT_WELCOME_MESSAGE}\n` },
+ });
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ await user.type(channel(), "town");
+ await user.click(await screen.findByRole("option", { name: "Town square" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ // The normalized default is never stored as custom text.
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ square,
+ "",
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("normalizes line endings and trims the message before sending", async () => {
+ const { setLanding, user } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: " Hi\r\nall " } });
+ expect(
+ screen.getByRole("region", { name: "Welcome to Primary" }),
+ ).toHaveTextContent("Hi all");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ "Hi\nall",
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("blocks messages the relay would refuse and restores the default in the draft", async () => {
+ const { setLanding, user } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: " \n " } });
+ expect(
+ screen.getByText("Enter a welcome message, or restore the default."),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(messageField(), { target: { value: "a".repeat(1001) } });
+ expect(screen.getByText("Shorten the message to 1,000 characters.")).toBeVisible();
+ // Unicode scalar values, not UTF-16 units, as the relay counts.
+ fireEvent.change(messageField(), { target: { value: "😀".repeat(1000) } });
+ expect(screen.getByText("1,000 of 1,000 characters")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ await user.click(
+ screen.getByRole("button", { name: "Restore default message" }),
+ );
+ expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE);
+ expect(setLanding).not.toHaveBeenCalled();
+});
+
+it("shows the relay's reason and keeps the draft when it refuses", async () => {
+ const { setLanding, user } = setup();
+ setLanding.mockRejectedValueOnce(
+ new PublishRejected(
+ "rejected",
+ "invalid: landing requires an open, unarchived channel without a ttl",
+ ),
+ );
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Landing requires an open, unarchived channel without a ttl",
+ );
+ expect(messageField()).toHaveValue("Say hello");
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+});
+
+it("asks to save again when the relay's change is not confirmed", async () => {
+ const { setLanding, user } = setup();
+ setLanding.mockRejectedValueOnce(
+ new ChannelLifecycleUnconfirmed(new Error("timeout")),
+ );
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Saved, but not confirmed yet. Save again to check.",
+ );
+ expect(messageField()).toHaveValue("Say hello");
+});
+
+it("offers a retry when the current welcome cannot be read", async () => {
+ const { readLanding, user } = setup();
+ readLanding.mockRejectedValueOnce(new Error("Relay unavailable"));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Welcome settings couldn’t be loaded. Relay unavailable",
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Retry loading welcome settings" }),
+ );
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+});
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
new file mode 100644
index 0000000000..7c84c5ac2a
--- /dev/null
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -0,0 +1,413 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ communityRequest,
+ type CommunityInfo,
+} from "../../features/communities/api";
+import {
+ DEFAULT_WELCOME_MESSAGE,
+ WelcomeCard,
+} from "../../features/communities/WelcomeCard";
+import { useChannelDirectory } from "../../features/channels/useChannelDirectory";
+import { ChannelLifecycleUnconfirmed } from "../../features/relay/channel-lifecycle";
+import { communityFromScope } from "../../features/relay/gifs";
+import {
+ normalizeWelcome,
+ WELCOME_MAX_LENGTH,
+ welcomeTextProblem,
+} from "../../features/relay/landing";
+import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
+import { useRelayConnection } from "../../features/relay/react";
+import type { RelayData } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
+import { matchName } from "../../features/search/match";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Combobox } from "../../shared/design-system/ui/Combobox";
+import { Field } from "../../shared/design-system/ui/Field";
+import { Header, InlineHeader } from "../../shared/design-system/ui/Header";
+import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion";
+import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
+import { Textarea } from "../../shared/design-system/ui/Textarea";
+import { ToastNotice } from "../../shared/design-system/ui/Toast";
+import styles from "./WelcomeSettings.module.css";
+
+const message = (reason: unknown) =>
+ reason instanceof Error ? reason.message : String(reason);
+const MAX = WELCOME_MAX_LENGTH.toLocaleString("en-US");
+
+/** What a failed save means for the draft, which is always kept. */
+function saveError(error: unknown) {
+ // Saving the same state again is idempotent, so saving again is the check.
+ if (error instanceof ChannelLifecycleUnconfirmed)
+ return "Saved, but not confirmed yet. Save again to check.";
+ if (error instanceof PublishNotSent) return "Not sent. Try again.";
+ if (error instanceof PublishRejected) {
+ const reason = error.reason?.replace(/^invalid:\s*/, "").trim();
+ return reason
+ ? `${reason.charAt(0).toUpperCase()}${reason.slice(1)}`
+ : "Only community owners and admins can change this.";
+ }
+ return message(error);
+}
+
+const problems = {
+ empty: "Enter a welcome message, or restore the default.",
+ long: `Shorten the message to ${MAX} characters.`,
+ control: "Use plain text. Line breaks are the only formatting.",
+};
+
+/** Community settings: where new members start and what they read there. */
+export function WelcomeSettings({
+ relay,
+ communityName,
+ active,
+}: {
+ relay: RelayData;
+ communityName?: string | undefined;
+ active(): boolean;
+}) {
+ const connection = useRelayConnection(relay);
+ const community =
+ connection.status === "ready" && connection.scope
+ ? communityFromScope(connection.scope)
+ : null;
+ return (
+
+
+ {community ? (
+
+ ) : (
+ Choose a connected community to set its welcome.
+ )}
+
+ );
+}
+
+/** The relay's current designation; `welcome` is `""` for the default. */
+type Published = Readonly<{ channelId?: string; name?: string; welcome: string }>;
+type Load =
+ | { status: "loading" }
+ | { status: "ready"; published: Published }
+ | { status: "unsupported" }
+ | { status: "error"; error: string };
+type Option = Readonly<{ id: string; name: string }>;
+
+function WelcomeForm({
+ session,
+ community,
+ communityName,
+ active,
+}: {
+ session: RelaySession;
+ community: string;
+ communityName?: string | undefined;
+ active(): boolean;
+}) {
+ const lifecycle = session.channelLifecycle;
+ const [attempt, setAttempt] = useState(0);
+ const [load, setLoad] = useState({ status: "loading" });
+ const [draft, setDraft] = useState<{ channelId?: string; text: string }>();
+ const [typed, setTyped] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [saved, setSaved] = useState(false);
+ const mounted = useRef(true);
+ const saveController = useRef(undefined);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ saveController.current?.abort();
+ };
+ }, []);
+ // Each open reads the live designation, then its signed message.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: An explicit retry reruns this finite read.
+ useEffect(() => {
+ const reading = new AbortController();
+ setLoad({ status: "loading" });
+ void (async () => {
+ try {
+ const info = await communityRequest(
+ community,
+ "info",
+ undefined,
+ reading.signal,
+ );
+ reading.signal.throwIfAborted();
+ if (!info.landingChannels) {
+ setLoad({ status: "unsupported" });
+ return;
+ }
+ const id = info.landingChannel;
+ const state = id
+ ? await lifecycle.readLanding(id, reading.signal)
+ : undefined;
+ reading.signal.throwIfAborted();
+ setLoad({
+ status: "ready",
+ published: {
+ ...(id && state ? { channelId: id } : {}),
+ ...(state?.name ? { name: state.name } : {}),
+ welcome: (state?.landing && state.welcome) || "",
+ },
+ });
+ } catch (reason) {
+ if (!reading.signal.aborted)
+ setLoad({ status: "error", error: message(reason) });
+ }
+ })();
+ return () => reading.abort();
+ }, [community, lifecycle, attempt]);
+ const published = load.status === "ready" ? load.published : undefined;
+ const directory = useChannelDirectory(session.channels, !!published);
+ const options = useMemo(() => {
+ const rows: Option[] = directory.directory
+ .filter((channel) => channel.channelType === "stream" && !channel.archived)
+ .map(({ id, name }) => ({ id, name }));
+ // The current welcome channel may be unlisted from the directory.
+ const current = published?.channelId;
+ if (current && !rows.some((row) => row.id === current))
+ rows.unshift({
+ id: current,
+ name: published.name ?? current.slice(0, 8),
+ });
+ return rows;
+ }, [directory.directory, published]);
+ const channelId = draft ? draft.channelId : published?.channelId;
+ const text = draft?.text ?? (published?.welcome || DEFAULT_WELCOME_MESSAGE);
+ const normalized = normalizeWelcome(text);
+ // The default is never stored: matching it restores the default.
+ const command = normalized === DEFAULT_WELCOME_MESSAGE ? "" : normalized;
+ const problem = welcomeTextProblem(normalized);
+ const dirty =
+ !!published &&
+ (channelId !== published.channelId || command !== published.welcome);
+ const selected = options.find((option) => option.id === channelId) ?? null;
+ // The input shows the chosen name; browsing from there lists every channel.
+ const needle = typed === selected?.name ? "" : typed.trim().toLowerCase();
+ const matches = useMemo(
+ () =>
+ needle
+ ? options
+ .flatMap((option) => {
+ const rank = matchName(option.name, needle)?.rank;
+ return rank === undefined ? [] : [{ option, rank }];
+ })
+ .sort((a, b) => a.rank - b.rank)
+ .map(({ option }) => option)
+ : options,
+ [options, needle],
+ );
+ const edit = (patch: { channelId?: string; text?: string }) => {
+ setDraft({
+ ...(channelId ? { channelId } : {}),
+ text,
+ ...patch,
+ });
+ setSaved(false);
+ setError("");
+ };
+ const disabled = saving || !published || !lifecycle.available;
+ return (
+
+
+ Welcome settings couldn’t be loaded. {load.error}
+
+ setAttempt((n) => n + 1)}>
+ Retry loading welcome settings
+
+
+ ) : load.status === "unsupported" ? (
+
+ This community’s relay doesn’t support a welcome channel.
+
+ ) : undefined
+ }
+ >
+
+
+ );
+}
diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx
index d7e6055cc1..539c72c699 100644
--- a/src/bundled/moderation/index.tsx
+++ b/src/bundled/moderation/index.tsx
@@ -1,11 +1,31 @@
import type { PluginModule } from "../../plugins/api";
import { CommunityAdmin } from "./CommunityAdmin";
import { canManageMembership } from "./membership";
+import { createWelcomeSupport } from "./welcome";
+import { WelcomeSettings } from "./WelcomeSettings";
export const inject = ["relay", "settingsCards", "adminAccess", "navigation"];
export const apply: PluginModule["apply"] = (ctx) => {
const relay = ctx.relay;
const membership = ctx.adminAccess.membership;
+ const manageable = () => {
+ const connection = relay.snapshot();
+ const state = membership.snapshot();
+ if (canManageMembership(state)) return true;
+ if (
+ connection.status === "error" ||
+ connection.status === "disconnected" ||
+ state.error
+ )
+ return "error";
+ if (
+ state.status === "idle" ||
+ state.status === "loading" ||
+ state.refreshing
+ )
+ return "pending";
+ return false;
+ };
// Registered in every build. The card hides unsupported member changes
// itself, so a Settings section, history entry or in-app locator naming it
// still opens the roster and invite controls in native builds.
@@ -14,24 +34,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
title: "Membership",
section: "administration",
visibility: {
- snapshot: () => {
- const connection = relay.snapshot();
- const state = membership.snapshot();
- if (canManageMembership(state)) return true;
- if (
- connection.status === "error" ||
- connection.status === "disconnected" ||
- state.error
- )
- return "error";
- if (
- state.status === "idle" ||
- state.status === "loading" ||
- state.refreshing
- )
- return "pending";
- return false;
- },
+ snapshot: manageable,
subscribe: membership.subscribe,
ensure: membership.ensure,
},
@@ -60,4 +63,44 @@ export const apply: PluginModule["apply"] = (ctx) => {
/>
),
});
+ // Owners and admins choose the welcome channel on relays that support it.
+ const support = createWelcomeSupport(relay);
+ ctx.effect(() => () => support.dispose());
+ ctx.settingsCards.register({
+ id: "welcome",
+ title: "Welcome",
+ section: "administration",
+ visibility: {
+ snapshot: () => {
+ const access = manageable();
+ if (access !== true) return access;
+ const state = support.snapshot();
+ if (state.status === "ready") return !!state.supported;
+ return state.status === "error" ? "error" : "pending";
+ },
+ subscribe(listener) {
+ const offMembership = membership.subscribe(listener);
+ const offSupport = support.subscribe(listener);
+ return () => {
+ offMembership();
+ offSupport();
+ };
+ },
+ ensure() {
+ const releaseMembership = membership.ensure();
+ const releaseSupport = support.ensure();
+ return () => {
+ releaseMembership();
+ releaseSupport();
+ };
+ },
+ },
+ component: ({ active, community }) => (
+
+ ),
+ });
};
diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts
index ef7633e9d7..051bace161 100644
--- a/src/bundled/moderation/registration.test.ts
+++ b/src/bundled/moderation/registration.test.ts
@@ -8,9 +8,15 @@ import { SettingsCardsService } from "../../features/settings/service";
import { createAdminAccess } from "../admin-access";
import type { CommunityReader } from "../../features/communities/service";
import type { RelayStaffBackend } from "../../features/relay-staff/contract";
+import { communityRequest } from "../../features/communities/api";
import { apply, inject } from "./index";
import manifest from "./manifest.json";
+vi.mock("../../features/communities/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ communityRequest: vi.fn(async () => ({ policy: null })),
+}));
+
const relayKey = "f".repeat(64);
const owner = "0".repeat(64);
@@ -97,3 +103,67 @@ it("registers the Membership card under the key the community rail opens", async
await root.fiber.dispose();
access.dispose();
});
+
+it.each([
+ [true, true],
+ [false, false],
+])(
+ "shows the Welcome card to owners when the relay advertises welcome channels: %s",
+ async (landingChannels, visible) => {
+ vi.mocked(communityRequest).mockResolvedValue({
+ policy: null,
+ landingChannels,
+ });
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const data = relay();
+ root.provide("relay", data);
+ const access = createAdminAccess(
+ data,
+ {
+ snapshot: () => ({
+ status: "ready",
+ selected: "https://primary.example",
+ viewer: owner,
+ }),
+ subscribe: () => () => {},
+ } as unknown as CommunityReader,
+ { available: false } as RelayStaffBackend,
+ );
+ root.provide("adminAccess", access);
+ const cards = new SettingsCardsService(root);
+ const releaseVisibility = cards.retainVisibility();
+ const fiber = root
+ .extend({ pluginOwner: { id: manifest.id, revision: "one" } })
+ .plugin({ name: manifest.id, inject, apply });
+ await fiber.await();
+ await flush();
+
+ await vi.waitFor(() =>
+ expect(cards.visibility("buzz.moderation/welcome")).toBe(visible),
+ );
+ expect(communityRequest).toHaveBeenCalledWith(
+ "https://primary.example",
+ "info",
+ undefined,
+ expect.any(AbortSignal),
+ );
+ const card = cards
+ .snapshot()
+ .find((entry) => entry.key === "buzz.moderation/welcome");
+ if (visible)
+ expect(card).toMatchObject({
+ title: "Welcome",
+ section: "administration",
+ });
+ else expect(card).toBeUndefined();
+
+ await fiber.dispose();
+ releaseVisibility();
+ await root.fiber.dispose();
+ access.dispose();
+ },
+);
diff --git a/src/bundled/moderation/welcome.ts b/src/bundled/moderation/welcome.ts
new file mode 100644
index 0000000000..9931783b55
--- /dev/null
+++ b/src/bundled/moderation/welcome.ts
@@ -0,0 +1,109 @@
+import {
+ communityRequest,
+ type CommunityInfo,
+} from "../../features/communities/api";
+import { communityFromScope } from "../../features/relay/gifs";
+import type { RelayData } from "../../features/relay/service";
+
+export type WelcomeSupport = Readonly<{
+ status: "idle" | "loading" | "ready" | "error";
+ /** The relay advertises the welcome-channel command. */
+ supported?: boolean;
+}>;
+
+const idle: WelcomeSupport = Object.freeze({ status: "idle" });
+
+/** Whether the connected community's relay supports a welcome channel, from
+ * its NIP-11 document. Each new demand reads it again, so a relay upgrade
+ * shows the card the next time Settings opens. */
+export function createWelcomeSupport(relay: RelayData) {
+ let value = idle;
+ let key = "";
+ let controller: AbortController | undefined;
+ let demands = 0;
+ let disposed = false;
+ const listeners = new Set<() => void>();
+ const publish = (next: WelcomeSupport) => {
+ value = Object.freeze(next);
+ for (const listener of listeners) listener();
+ };
+ const current = () => {
+ const connection = relay.snapshot();
+ const nextKey = `${connection.generation}:${connection.status === "ready" ? (connection.scope ?? "") : ""}`;
+ if (nextKey !== key) {
+ key = nextKey;
+ controller?.abort();
+ value = idle;
+ }
+ return connection;
+ };
+ const load = (fresh: boolean) => {
+ const connection = current();
+ if (
+ disposed ||
+ !demands ||
+ connection.status !== "ready" ||
+ !connection.scope ||
+ (!fresh && (value.status === "loading" || value.status === "ready"))
+ )
+ return;
+ const community = communityFromScope(connection.scope);
+ if (!community) return;
+ controller?.abort();
+ const owner = new AbortController();
+ controller = owner;
+ const capturedKey = key;
+ const retained = value.status === "ready" ? value : undefined;
+ if (!retained) publish({ status: "loading" });
+ communityRequest(
+ community,
+ "info",
+ undefined,
+ owner.signal,
+ ).then(
+ (info) => {
+ if (owner.signal.aborted || capturedKey !== key || disposed) return;
+ publish({ status: "ready", supported: !!info.landingChannels });
+ },
+ () => {
+ if (owner.signal.aborted || capturedKey !== key || disposed) return;
+ // A failed re-read keeps the support already shown.
+ if (!retained) publish({ status: "error" });
+ },
+ );
+ };
+ const stop = relay.subscribe(() => {
+ const before = key;
+ current();
+ for (const listener of listeners) listener();
+ if (before !== key) load(false);
+ });
+ return {
+ snapshot: () => value,
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ ensure() {
+ const reacquired = demands === 0;
+ demands++;
+ load(reacquired);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ demands--;
+ };
+ },
+ dispose() {
+ disposed = true;
+ stop();
+ controller?.abort();
+ listeners.clear();
+ },
+ };
+}
+
+export type WelcomeSupportStore = ReturnType;
diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css
new file mode 100644
index 0000000000..46222580ab
--- /dev/null
+++ b/src/features/communities/WelcomeCard.module.css
@@ -0,0 +1,17 @@
+.card {
+ background: var(--surface-inset);
+ border-radius: var(--radius-container);
+ padding: var(--space-4) var(--space-5);
+ color: var(--text-standard);
+ text-align: start;
+}
+.title {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.body {
+ margin: var(--space-1) 0 0;
+ /* Plain text: line breaks are the only formatting. */
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
new file mode 100644
index 0000000000..9f3a852c61
--- /dev/null
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -0,0 +1,43 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, it } from "vitest";
+import type { CommunityReader } from "./service";
+import {
+ CommunityWelcomeCard,
+ DEFAULT_WELCOME_MESSAGE,
+ WelcomeCard,
+} from "./WelcomeCard";
+
+afterEach(cleanup);
+
+it("titles the welcome with the community and shows the default message", () => {
+ render( );
+ const card = screen.getByRole("region", { name: "Welcome to Primary" });
+ expect(card).toHaveTextContent(DEFAULT_WELCOME_MESSAGE);
+});
+
+it("shows a custom message as plain text", () => {
+ render(all"} />);
+ const card = screen.getByRole("region", { name: "Welcome to Primary" });
+ expect(card).toHaveTextContent("**Hi** all ");
+ expect(card.querySelector("b, strong")).toBeNull();
+});
+
+it("names the community the viewer saved, falling back to a plain title", () => {
+ const communities = {
+ snapshot: () => ({
+ memberships: [{ id: "https://primary.example", name: "Primary" }],
+ }),
+ subscribe: () => () => {},
+ } as unknown as CommunityReader;
+ const scope = `https://primary.example:${"0".repeat(64)}`;
+ const { rerender } = render(
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Welcome to Primary" }),
+ ).toBeVisible();
+ rerender( );
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+});
diff --git a/src/features/communities/WelcomeCard.tsx b/src/features/communities/WelcomeCard.tsx
new file mode 100644
index 0000000000..35c37b07b5
--- /dev/null
+++ b/src/features/communities/WelcomeCard.tsx
@@ -0,0 +1,63 @@
+import { useId, useSyncExternalStore } from "react";
+import { splitPartition } from "../relay/partition";
+import { communityPresentation } from "./community-icon";
+import type { CommunityReader } from "./service";
+import styles from "./WelcomeCard.module.css";
+
+/** Shown when the community has not set its own welcome message. */
+export const DEFAULT_WELCOME_MESSAGE =
+ "This is a place to connect with the community and keep up with what’s happening. Explore the channels to find conversations that interest you.";
+
+/** The welcome channel's introduction; a timeline start, never a message. */
+export function WelcomeCard({
+ communityName,
+ message,
+ level = 2,
+}: {
+ communityName?: string | undefined;
+ /** Plain text; `undefined` shows the default. */
+ message?: string | undefined;
+ level?: 2 | 3 | 4;
+}) {
+ const title = useId();
+ const Heading = `h${level}` as "h2" | "h3" | "h4";
+ return (
+
+
+ {communityName ? `Welcome to ${communityName}` : "Welcome"}
+
+
+ {message ?? DEFAULT_WELCOME_MESSAGE}
+
+
+ );
+}
+
+const noCommunities = () => () => {};
+const noSnapshot = () => undefined;
+
+/** The welcome card titled with the name this viewer knows the community by. */
+export function CommunityWelcomeCard({
+ communities,
+ scope,
+ message,
+}: {
+ communities?: CommunityReader | undefined;
+ /** The relay session's partition. */
+ scope: string;
+ message?: string | undefined;
+}) {
+ const snapshot = useSyncExternalStore(
+ communities?.subscribe ?? noCommunities,
+ communities?.snapshot ?? noSnapshot,
+ );
+ const origin = splitPartition(scope)?.communityOrigin;
+ let name: string | undefined;
+ if (origin && origin !== "offline")
+ try {
+ name = communityPresentation(origin, snapshot?.memberships).name;
+ } catch {
+ // An unscoped reader has no community presentation to show.
+ }
+ return ;
+}
diff --git a/src/features/communities/api.ts b/src/features/communities/api.ts
index c67cb0f5ef..b3eec77d6b 100644
--- a/src/features/communities/api.ts
+++ b/src/features/communities/api.ts
@@ -15,6 +15,10 @@ export type CommunityInfo = {
name?: string;
icon?: string;
policy: JoinPolicy | null;
+ /** The relay advertises `buzz-landing-channel`: admins can set a welcome channel. */
+ landingChannels?: boolean;
+ /** The designated welcome channel, read live from NIP-11. */
+ landingChannel?: string;
};
export async function communityRequest(
id: string,
diff --git a/src/features/communities/native-api.test.ts b/src/features/communities/native-api.test.ts
index e9fe0fe5ec..03c653c5ef 100644
--- a/src/features/communities/native-api.test.ts
+++ b/src/features/communities/native-api.test.ts
@@ -441,3 +441,25 @@ it.each([
expect(info.membershipRequired).toBe(nips.includes(43));
},
);
+
+it("projects welcome-channel support and designation from NIP-11", async () => {
+ const landing = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11";
+ vi.mocked(invoke).mockImplementation(async (_command, args) => ({
+ status: 200,
+ headers: {},
+ body: JSON.stringify(
+ (args as { path: string }).path === "/"
+ ? {
+ self: key.pubkey,
+ supported_extensions: ["buzz-landing-channel"],
+ landing_channel: landing,
+ }
+ : { policy: null },
+ ),
+ }));
+ const info = await communityRequest(community, "info");
+ expect(info).toMatchObject({
+ landingChannels: true,
+ landingChannel: landing,
+ });
+});
diff --git a/src/features/communities/native-api.ts b/src/features/communities/native-api.ts
index 405855a184..89f69e1816 100644
--- a/src/features/communities/native-api.ts
+++ b/src/features/communities/native-api.ts
@@ -12,6 +12,7 @@ import { leaveRefusal, leaveRequestTemplate } from "./leave-protocol";
import { adminReason, memberCommand } from "./admin-protocol";
import { readRelayLibrary } from "../agents/relay-library";
import { eventDto } from "../relay/events";
+import { landingInfo } from "../relay/landing";
import { readApiFailure } from "../relay/http-admission";
import { admitSignedRequest } from "../relay/transport";
import type { PersonalProfile } from "./service";
@@ -69,6 +70,7 @@ export async function nativeCommunityRequest(
response.status === 404
? null
: readJoinPolicy(await readResponse(response)),
+ ...landingInfo(info),
};
}
if (route === "session") {
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index e8971b02dc..fce92ea073 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -190,6 +190,9 @@ export type ChannelTimelineProps = {
chatPresentation?: boolean | undefined;
/** False: rows open existing threads but offer none to start; see MessageRow. */
startsThreads?: boolean | undefined;
+ /** Shown above the first message once all history is loaded; it is not a
+ * message and replaces the empty-conversation notice. */
+ start?: ReactNode;
onOpenLink(url: string, origin?: MessageLinkOrigin): boolean;
canOpenLink?: ((target: string) => boolean) | undefined;
revealMessageId?: string | undefined;
@@ -251,6 +254,7 @@ function Timeline({
onOpenMediaReview,
chatPresentation = false,
startsThreads = true,
+ start,
}: ChannelTimelineProps) {
useMessageSelectionCopy();
const shownIntro =
@@ -760,6 +764,12 @@ function Timeline({
window.error)
)
setReserveHistorySpace(true);
+ // The measured edge carries the start content, so Virtua offsets rows below it.
+ const atStart =
+ !!start &&
+ window.status === "ready" &&
+ !window.hasMore &&
+ !window.historyLimited;
const handle = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0, edgeHeight: 0 });
const width = size.width;
@@ -1907,9 +1917,11 @@ function Timeline({
className={styles.edge}
data-history-space={reserveHistorySpace || undefined}
data-intro={!!shownIntro || undefined}
+ data-start={atStart || undefined}
>
{shownIntro}
{edgeError && {edgeError} }
+ {atStart && start}
{window.historyLimited ? (
History window limit reached
) : window.hasMore && queries.channels.cachedOnly ? (
@@ -2152,6 +2164,7 @@ function Timeline({
{!rows.length &&
!shownIntro &&
+ !atStart &&
!window.hasMore &&
window.status === "ready" &&
!window.error &&
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index 040c737773..b1f17c4fec 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -110,6 +110,12 @@
.edge[data-history-space] {
min-height: calc(var(--size-control) + var(--space-4));
}
+/* Start content stacks at the reading measure, aligned with the rows below. */
+.edge[data-start] {
+ flex-direction: column;
+ align-items: stretch;
+ padding-block: var(--space-panel-inset) var(--space-2);
+}
.feed[data-chat-presentation] .edge:not([data-history-space]) {
min-height: var(--space-1);
}
diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts
index 6b4c8fe154..0a9ef09211 100644
--- a/src/features/relay/channel-lifecycle-protocol.ts
+++ b/src/features/relay/channel-lifecycle-protocol.ts
@@ -1,6 +1,7 @@
import type { EventTemplate } from "nostr-tools";
import type { ChannelSummary } from "./contracts.ts";
import { newer, type RelayEvent } from "./events.ts";
+import { validWelcomeCommand } from "./landing.ts";
export type ChannelLifecycleAction =
| "archive"
@@ -57,6 +58,36 @@ export function lifecycleTemplate(
return event;
}
+/**
+ * Makes a channel the community's welcome channel with its full welcome
+ * state. `""` restores the default message; the relay accepts a repeat.
+ */
+export function landingTemplate(channelId: string, welcome: string) {
+ const event = {
+ kind: 9002,
+ created_at: Math.floor(Date.now() / 1000),
+ content: "",
+ tags: [
+ ["h", lifecycleChannelId(channelId)],
+ ["landing", "true"],
+ ["welcome", welcome],
+ ],
+ };
+ validateLifecycleTemplate(event);
+ return event;
+}
+
+function landingCommand(tags: string[][]) {
+ return (
+ tags.length === 3 &&
+ JSON.stringify(tags[1]) === JSON.stringify(["landing", "true"]) &&
+ tags[2]?.length === 2 &&
+ tags[2][0] === "welcome" &&
+ typeof tags[2][1] === "string" &&
+ validWelcomeCommand(tags[2][1])
+ );
+}
+
/** The host never accepts generic metadata edits or other-member removal. */
export function validateLifecycleTemplate(event: EventTemplate): void {
if (
@@ -66,12 +97,20 @@ export function validateLifecycleTemplate(event: EventTemplate): void {
!Number.isSafeInteger(event.created_at) ||
event.created_at < 0 ||
!Array.isArray(event.tags) ||
- event.tags.length !== (event.kind === 9002 ? 2 : 1) ||
+ !(
+ event.tags.length === (event.kind === 9002 ? 2 : 1) ||
+ (event.kind === 9002 && event.tags.length === 3)
+ ) ||
event.tags[0]?.length !== 2 ||
event.tags[0]?.[0] !== "h"
)
throw new Error("Invalid channel lifecycle command");
lifecycleChannelId(event.tags[0]?.[1] ?? "");
+ if (event.kind === 9002 && event.tags.length === 3) {
+ if (!landingCommand(event.tags))
+ throw new Error("Only the welcome channel and message may be set here");
+ return;
+ }
if (
event.kind === 9002 &&
!["archived", "auto_join"].some((name) =>
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index 9d0fed26e4..0aca0cd1da 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -20,6 +20,7 @@ import {
ChannelLifecycleUnconfirmed,
} from "./channel-lifecycle";
import {
+ landingTemplate,
lifecycleSettings,
lifecycleTemplate,
validateLifecycleTemplate,
@@ -2224,6 +2225,267 @@ describe("auto-join switch", () => {
});
});
+describe("welcome channel", () => {
+ // The viewer is a relay owner/admin but need not belong to the channel.
+ function welcomeStream(tags: string[][] = [["public"]], type = "stream") {
+ const h = harness("member", type);
+ const metadata = (landing: string[][] = []) =>
+ h.record(39000, [
+ ["d", id],
+ ["t", type],
+ ["name", "Lobby"],
+ ...tags,
+ ...landing,
+ ]);
+ h.setEvents([metadata(), ...h.getEvents().slice(1)]);
+ h.deny();
+ h.publish.mockImplementation(async (event: RelayEvent) => {
+ if (event.kind !== 9002) return;
+ const text = event.tags.find(([key]) => key === "welcome")?.[1];
+ h.setEvents([
+ metadata([["landing"], ...(text ? [["welcome", text]] : [])]),
+ ...h.getEvents().filter((event) => event.kind !== 39000),
+ ]);
+ });
+ return { ...h, metadata };
+ }
+
+ it("sends the full-state command and confirms the strong readback", async () => {
+ const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+ const h = welcomeStream();
+ try {
+ await h.owner.capability.setLanding(id, "Say hi\nhere");
+ expect(h.sign.mock.calls[0]?.[0]).toEqual(
+ landingTemplate(id, "Say hi\nhere"),
+ );
+ expect(h.sign.mock.calls[0]?.[0]?.tags).toEqual([
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "Say hi\nhere"],
+ ]);
+ expect(h.publish).toHaveBeenCalledOnce();
+ // Only signed metadata is read, and the confirmation is strong.
+ for (const [filters] of h.read.mock.calls)
+ expect(filters.map((filter) => filter.kinds)).toEqual([[39000]]);
+ expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({
+ consistency: "strong",
+ "#d": [id],
+ });
+ // A channel the viewer cannot take part in stays out of discovery.
+ expect(h.acceptDiscovery).not.toHaveBeenCalled();
+ } finally {
+ clock.mockRestore();
+ h.owner.dispose();
+ }
+ });
+
+ it("restores the default with an empty message and confirms its absence", async () => {
+ const h = welcomeStream();
+ h.setEvents([
+ h.metadata([["landing"], ["welcome", "Old"]]),
+ ...h.getEvents().slice(1),
+ ]);
+ try {
+ await h.owner.capability.setLanding(id, "");
+ expect(h.sign.mock.calls[0]?.[0]?.tags[2]).toEqual(["welcome", ""]);
+ await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
+ landing: true,
+ name: "Lobby",
+ });
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
+ it("reads the current message, ignoring one without the designation", async () => {
+ const h = welcomeStream();
+ try {
+ h.setEvents([h.metadata([["landing"], ["welcome", "Hi"]])]);
+ await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
+ landing: true,
+ welcome: "Hi",
+ name: "Lobby",
+ });
+ h.setEvents([h.metadata([["welcome", "Stale"]])]);
+ await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
+ name: "Lobby",
+ });
+ h.setEvents([]);
+ await expect(h.owner.capability.readLanding(id)).resolves.toBeUndefined();
+ expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({
+ consistency: "strong",
+ });
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
+ it("accepts the confirmed metadata into discovery for a channel the viewer is in", async () => {
+ const h = harness("member");
+ h.publish.mockImplementation(async () => {
+ h.setEvents([
+ h.record(39000, [
+ ["d", id],
+ ["t", "stream"],
+ ["public"],
+ ["landing"],
+ ]),
+ ...h.getEvents().slice(1),
+ ]);
+ });
+ h.setEvents([
+ h.record(39000, [
+ ["d", id],
+ ["t", "stream"],
+ ["public"],
+ ]),
+ ...h.getEvents().slice(1),
+ ]);
+ try {
+ await h.owner.capability.setLanding(id, "");
+ expect(h.acceptDiscovery).toHaveBeenCalledOnce();
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
+ it.each([
+ [[["public"], ["private"]], "stream"],
+ [[], "stream"],
+ [[["public"], ["archived", "true"]], "stream"],
+ [[["public"], ["ttl", "86400"]], "stream"],
+ [[["public"]], "forum"],
+ ])(
+ "refuses metadata %j on a %s channel before signing",
+ async (tags, type) => {
+ const h = welcomeStream(tags, type);
+ try {
+ await expect(h.owner.capability.setLanding(id, "")).rejects.toThrow(
+ "Only active public channels that don’t expire can welcome new members.",
+ );
+ expect(h.sign).not.toHaveBeenCalled();
+ } finally {
+ h.owner.dispose();
+ }
+ },
+ );
+
+ it.each([" ", "a".repeat(1001), "tab\there"])(
+ "refuses an invalid message %j before reading or signing",
+ async (text) => {
+ const h = welcomeStream();
+ try {
+ await expect(h.owner.capability.setLanding(id, text)).rejects.toThrow();
+ expect(h.read).not.toHaveBeenCalled();
+ expect(h.sign).not.toHaveBeenCalled();
+ } finally {
+ h.owner.dispose();
+ }
+ },
+ );
+
+ it("surfaces the relay's refusal and keeps an unconfirmed move uncertain", async () => {
+ const h = welcomeStream();
+ try {
+ h.publish.mockRejectedValueOnce(
+ new PublishRejected(
+ "rejected",
+ "invalid: landing requires an open, unarchived channel without a ttl",
+ ),
+ );
+ await expect(h.owner.capability.setLanding(id, "")).rejects.toMatchObject(
+ {
+ reason:
+ "invalid: landing requires an open, unarchived channel without a ttl",
+ },
+ );
+ // An acknowledged write whose readback never shows the designation.
+ h.publish.mockImplementationOnce(async () => {});
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ const pending = expect(
+ h.owner.capability.setLanding(id, ""),
+ ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed);
+ await vi.advanceTimersByTimeAsync(2500);
+ await pending;
+ // A readback whose message differs is not a confirmation either.
+ h.publish.mockImplementationOnce(async () => {
+ h.setEvents([h.metadata([["landing"], ["welcome", "Other"]])]);
+ });
+ const differing = expect(
+ h.owner.capability.setLanding(id, "Mine"),
+ ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed);
+ await vi.advanceTimersByTimeAsync(2500);
+ await differing;
+ } finally {
+ vi.useRealTimers();
+ h.owner.dispose();
+ }
+ });
+
+ it("publishes nothing once the caller's guard turns false", async () => {
+ const h = welcomeStream();
+ try {
+ await expect(
+ h.owner.capability.setLanding(id, "", undefined, () => false),
+ ).rejects.toMatchObject({ name: "AbortError" });
+ expect(h.publish).not.toHaveBeenCalled();
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
+ it("admits only the exact landing command", () => {
+ const base = { kind: 9002, content: "", created_at: 1 };
+ expect(() =>
+ validateLifecycleTemplate({
+ ...base,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ],
+ }),
+ ).not.toThrow();
+ for (const tags of [
+ [
+ ["h", id],
+ ["landing", "false"],
+ ["welcome", ""],
+ ],
+ [
+ ["h", id],
+ ["welcome", ""],
+ ["landing", "true"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", " "],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "a\tb"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "", "extra"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["name", "x"],
+ ],
+ ])
+ expect(() => validateLifecycleTemplate({ ...base, tags })).toThrow();
+ });
+});
+
describe("caller publication guard", () => {
function setup(action: "archive" | "unarchive") {
const h = harness();
diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts
index 92c7521ac0..62dcdf5bd4 100644
--- a/src/features/relay/channel-lifecycle.ts
+++ b/src/features/relay/channel-lifecycle.ts
@@ -1,4 +1,4 @@
-import { getEventHash } from "nostr-tools";
+import { getEventHash, type EventTemplate } from "nostr-tools";
import { attestedOwner } from "../agents/owner-attestation";
import type { RelayReader } from "./reader";
import type { RelayWriter } from "./transport";
@@ -7,7 +7,8 @@ import { isTransientFailure, readErrorKind } from "./errors";
import { retryRead } from "./read-retry";
import { yieldToHost } from "./yield";
import { hasTag, newer, type RelayEvent } from "./events";
-import { openMetadata } from "./discovery";
+import { metadataName, openMetadata } from "./discovery";
+import { landingTags } from "./landing";
import {
channelTypeTag,
DM_VISIBILITY_KIND,
@@ -16,6 +17,7 @@ import {
lifecycleRecord,
lifecycleSettings,
lifecycleTemplate,
+ landingTemplate,
ownerAgentGrant,
type ChannelLifecycleCommand,
type ChannelLifecycleSettings,
@@ -31,6 +33,14 @@ export class ChannelLifecycleUnconfirmed extends Error {
}
}
+/** A channel's signed welcome state. `welcome` is absent for the default message. */
+export type ChannelLanding = Readonly<{
+ /** Signed metadata currently designates this channel. */
+ landing?: true;
+ welcome?: string;
+ name?: string;
+}>;
+
export type DmVisibility = Readonly<{
status: "idle" | "loading" | "ready" | "error";
hidden: readonly string[];
@@ -56,6 +66,20 @@ export interface ChannelLifecycleCapability {
subscribe(listener: () => void): () => void;
refreshVisibility(signal?: AbortSignal): Promise;
observeRemoval(listener: (channelId: string) => void): () => void;
+ /** Strong read of relay-signed channel metadata; no membership needed. */
+ readLanding(
+ channelId: string,
+ signal?: AbortSignal,
+ ): Promise;
+ /** Owner/admin command: make this the community's welcome channel with
+ * `welcome` as its message, `""` for the default. */
+ setLanding(
+ channelId: string,
+ welcome: string,
+ signal?: AbortSignal,
+ /** Checked synchronously right before publishing; false publishes nothing. */
+ active?: () => boolean,
+ ): Promise;
}
export function createChannelLifecycle({
@@ -171,6 +195,50 @@ export function createChannelLifecycle({
signal.throwIfAborted();
}
}
+ /** Each channel's commands keep strictly increasing timestamps. */
+ function issue(id: string, fresh: EventTemplate): EventTemplate {
+ const template = {
+ ...fresh,
+ created_at: Math.max(fresh.created_at, (issued.get(id) ?? -1) + 1),
+ };
+ issued.set(id, template.created_at);
+ return template;
+ }
+ async function sign(signer: RelayWriter, template: EventTemplate, signal: AbortSignal) {
+ const signed = await signer.sign(structuredClone(template), signal);
+ signal.throwIfAborted();
+ if (
+ signed.pubkey !== viewer ||
+ signed.kind !== template.kind ||
+ signed.created_at !== template.created_at ||
+ signed.content !== template.content ||
+ JSON.stringify(signed.tags) !== JSON.stringify(template.tags) ||
+ getEventHash(signed) !== signed.id
+ )
+ throw new Error("Signer changed the channel lifecycle command");
+ return signed;
+ }
+ function pause(delay: number, signal: AbortSignal) {
+ signal.throwIfAborted();
+ if (!delay) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const abort = () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ };
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", abort);
+ resolve();
+ }, delay);
+ signal.addEventListener("abort", abort, { once: true });
+ });
+ }
+ async function readMetadata(id: string, signal: AbortSignal, strong = false) {
+ const events = await reread(signal, () =>
+ read([39000], id, signal, false, strong),
+ );
+ return lifecycleRecord(events, 39000, id, relayAuthor);
+ }
type OwnedAgentAction = "archive" | "unarchive" | "delete";
async function load(
id: string,
@@ -461,23 +529,11 @@ export function createChannelLifecycle({
);
};
if (await authorize()) return;
- const fresh = lifecycleTemplate(action, id);
- const template = {
- ...fresh,
- created_at: Math.max(fresh.created_at, (issued.get(id) ?? -1) + 1),
- };
- issued.set(id, template.created_at);
- const signed = await writer.sign(structuredClone(template), signal);
- signal.throwIfAborted();
- if (
- signed.pubkey !== viewer ||
- signed.kind !== template.kind ||
- signed.created_at !== template.created_at ||
- signed.content !== template.content ||
- JSON.stringify(signed.tags) !== JSON.stringify(template.tags) ||
- getEventHash(signed) !== signed.id
- )
- throw new Error("Signer changed the channel lifecycle command");
+ const signed = await sign(
+ writer,
+ issue(id, lifecycleTemplate(action, id)),
+ signal,
+ );
if (await authorize()) return;
signal.throwIfAborted();
if (active?.() === false)
@@ -487,19 +543,7 @@ export function createChannelLifecycle({
// An accepted write may precede its relay-owned side effects. Do not
// optimistically revoke membership or confuse a failed read with absence.
for (const delay of [0, 250, 750, 1500]) {
- signal.throwIfAborted();
- if (delay)
- await new Promise((resolve, reject) => {
- const abort = () => {
- clearTimeout(timer);
- reject(signal.reason);
- };
- const timer = setTimeout(() => {
- signal.removeEventListener("abort", abort);
- resolve();
- }, delay);
- signal.addEventListener("abort", abort, { once: true });
- });
+ await pause(delay, signal);
if (action === "hide") {
const hidden = await reread(signal, () =>
readVisibility(signal, true),
@@ -563,6 +607,82 @@ export function createChannelLifecycle({
busy = false;
}
},
+ async readLanding(value: string, caller?: AbortSignal) {
+ const id = lifecycleChannelId(value);
+ return owned({ id, caller }, (signal) =>
+ retryRead(async () => {
+ const record = await readMetadata(id, signal, true);
+ if (!record) return undefined;
+ const name = metadataName(record);
+ return Object.freeze({
+ ...landingTags(record.tags),
+ ...(name ? { name } : {}),
+ });
+ }, signal),
+ );
+ },
+ async setLanding(
+ value: string,
+ welcome: string,
+ caller?: AbortSignal,
+ active?: () => boolean,
+ ) {
+ if (!available || !writer)
+ throw new Error("Channel actions are unavailable on this connection");
+ if (busy) throw new Error("Another channel action is still in progress");
+ const id = lifecycleChannelId(value);
+ const template = landingTemplate(id, welcome);
+ busy = true;
+ const scope: Scope = { id, caller };
+ try {
+ await owned(scope, async (signal) => {
+ // The relay accepts this only from community owners and admins, who
+ // need not belong to the channel. Check what signed metadata can
+ // show: an active public stream channel without an expiry.
+ const eligible = async () => {
+ const channel = await readMetadata(id, signal);
+ if (
+ !channel ||
+ !openMetadata(channel) ||
+ exactLifecycleTag(channel, "archived") === "true" ||
+ channelTypeTag(channel) !== "stream" ||
+ channel.tags.some(([key]) => key === "ttl")
+ )
+ throw new Error(
+ "Only active public channels that don’t expire can welcome new members.",
+ );
+ };
+ await eligible();
+ const signed = await sign(writer, issue(id, template), signal);
+ await eligible();
+ signal.throwIfAborted();
+ if (active?.() === false)
+ throw new DOMException("Channel action cancelled", "AbortError");
+ scope.published = true;
+ await writer.publish(signed, signal);
+ // Readback is the bare `landing` designation plus the stored message,
+ // absent for the default.
+ for (const delay of [0, 250, 750, 1500]) {
+ await pause(delay, signal);
+ const record = await readMetadata(id, signal, true);
+ const state = record && landingTags(record.tags);
+ if (record && state?.landing && (state.welcome ?? "") === welcome) {
+ if (canAccess(id)) acceptDiscovery([record]);
+ return;
+ }
+ }
+ throw new Error(
+ "The relay accepted the request, but the change is not confirmed.",
+ );
+ });
+ } catch (error) {
+ if (scope.published && !(error instanceof PublishRejected))
+ throw new ChannelLifecycleUnconfirmed(error);
+ throw error;
+ } finally {
+ busy = false;
+ }
+ },
});
return {
capability,
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 8c3b2448e9..925fb3b662 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -53,6 +53,10 @@ export type ChannelSummary = Readonly<{
private?: true;
/** Relay-advertised default: the relay joins new community members here. */
autoJoin?: true;
+ /** Relay-signed welcome channel: new members join and open it first. */
+ landing?: true;
+ /** Custom welcome message; only present on the welcome channel. */
+ welcome?: string;
/** Presentation-only parent from signed channel metadata; never grants access. */
parentChannelId?: string | undefined;
/** Marked Huddle room; presented through its parent conversation, not the sidebar. */
diff --git a/src/features/relay/discovery-landing.test.ts b/src/features/relay/discovery-landing.test.ts
new file mode 100644
index 0000000000..77c3ce11df
--- /dev/null
+++ b/src/features/relay/discovery-landing.test.ts
@@ -0,0 +1,48 @@
+import { expect, it } from "vitest";
+import { DiscoveryState } from "./discovery";
+import { keypair, metadata, roster } from "./testing";
+
+it("marks the welcome channel and its message from relay-signed metadata", () => {
+ const viewer = keypair(),
+ relay = keypair();
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(roster(relay, "general", [viewer.pubkey]));
+ state.accept(metadata(relay, "general", "General"));
+ expect(state.get("general")?.landing).toBeUndefined();
+
+ state.accept(
+ metadata(relay, "general", "General", 1_700_000_001, [
+ ["landing"],
+ ["welcome", "Say hi"],
+ ]),
+ );
+ expect(state.get("general")).toMatchObject({
+ landing: true,
+ welcome: "Say hi",
+ });
+
+ // Moving the designation re-emits this channel without it; a surviving
+ // message no longer applies.
+ state.accept(
+ metadata(relay, "general", "General", 1_700_000_002, [
+ ["welcome", "Say hi"],
+ ]),
+ );
+ expect(state.get("general")?.landing).toBeUndefined();
+ expect(state.get("general")?.welcome).toBeUndefined();
+});
+
+it("ignores a non-relay-authored landing tag", () => {
+ const viewer = keypair(),
+ relay = keypair(),
+ stranger = keypair();
+ const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
+ state.accept(roster(relay, "general", [viewer.pubkey]));
+ state.accept(metadata(relay, "general", "General"));
+ expect(
+ state.accept(
+ metadata(stranger, "general", "General", 1_700_000_001, [["landing"]]),
+ ),
+ ).toBe(false);
+ expect(state.get("general")?.landing).toBeUndefined();
+});
diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts
index bc01b0ab37..44bb1f8a45 100644
--- a/src/features/relay/discovery.ts
+++ b/src/features/relay/discovery.ts
@@ -10,6 +10,7 @@ import { objectBody } from "./body";
import { newer, hasTag, tag, type RelayEvent } from "./events";
import type { ChannelSummary } from "./contracts";
import { channelIdentity } from "./channel-labels";
+import { landingTags } from "./landing";
const names = new WeakMap();
/** Display name from signed channel metadata; the content body overrides the tag. */
@@ -456,6 +457,7 @@ export class DiscoveryState {
...(this.hidden(id) ? { hidden: true } : {}),
...(this.isPrivate(id) ? { private: true } : {}),
...(this.autoJoin(id) ? { autoJoin: true } : {}),
+ ...(event ? landingTags(event.tags) : {}),
...(channelType ? { channelType } : {}),
...(channelType === "solo" && event
? { updatedAt: event.created_at }
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
new file mode 100644
index 0000000000..ee35e0a704
--- /dev/null
+++ b/src/features/relay/landing.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import {
+ landingInfo,
+ landingTags,
+ normalizeWelcome,
+ validWelcomeCommand,
+ welcomeTextProblem,
+} from "./landing";
+
+describe("landingTags", () => {
+ it("reads the bare designation and its custom message", () => {
+ expect(landingTags([["d", "x"]])).toEqual({});
+ expect(landingTags([["landing"]])).toEqual({ landing: true });
+ expect(landingTags([["landing"], ["welcome", "Hi\nall"]])).toEqual({
+ landing: true,
+ welcome: "Hi\nall",
+ });
+ });
+
+ it("ignores a message without the designation", () => {
+ // A stale tag can survive on the previous welcome channel.
+ expect(landingTags([["welcome", "Old"]])).toEqual({});
+ });
+
+ it("falls back to the default for a blank, repeated or invalid message", () => {
+ for (const tags of [
+ [["landing"], ["welcome", ""]],
+ [["landing"], ["welcome", " "]],
+ [["landing"], ["welcome"]],
+ [["landing"], ["welcome", "a"], ["welcome", "b"]],
+ [["landing"], ["welcome", "tab\there"]],
+ ])
+ expect(landingTags(tags)).toEqual({ landing: true });
+ });
+});
+
+describe("welcome text", () => {
+ it("normalizes line endings and trims before sending", () => {
+ expect(normalizeWelcome(" Hi\r\nall\rthere \n")).toBe("Hi\nall\nthere");
+ });
+
+ it("mirrors the relay's limits in Unicode scalar values", () => {
+ expect(welcomeTextProblem("é".repeat(1000))).toBeUndefined();
+ // Surrogate pairs count once, as Rust `chars()` does.
+ expect(welcomeTextProblem("😀".repeat(1000))).toBeUndefined();
+ expect(welcomeTextProblem("a".repeat(1001))).toBe("long");
+ expect(welcomeTextProblem(" \n ")).toBe("empty");
+ expect(welcomeTextProblem("line\nbreak")).toBeUndefined();
+ for (const text of ["tab\t", "cr\r", "nul\u0000", "del\u007f", "c1\u0085"])
+ expect(welcomeTextProblem(text)).toBe("control");
+ });
+
+ it("admits the empty default marker only as a command", () => {
+ expect(validWelcomeCommand("")).toBe(true);
+ expect(validWelcomeCommand("Hello")).toBe(true);
+ expect(validWelcomeCommand(" ")).toBe(false);
+ });
+});
+
+describe("landingInfo", () => {
+ const id = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11";
+
+ it("reads support and the designated channel from NIP-11", () => {
+ expect(
+ landingInfo({
+ supported_extensions: ["buzz-gif", "buzz-landing-channel"],
+ landing_channel: id,
+ }),
+ ).toEqual({ landingChannels: true, landingChannel: id });
+ expect(
+ landingInfo({ supported_extensions: ["buzz-landing-channel"] }),
+ ).toEqual({ landingChannels: true });
+ });
+
+ it("ignores a designation from a relay without the extension", () => {
+ expect(landingInfo({ landing_channel: id })).toEqual({
+ landingChannels: false,
+ });
+ expect(landingInfo(undefined)).toEqual({ landingChannels: false });
+ });
+
+ it("ignores a malformed designation", () => {
+ for (const landing_channel of [id.toUpperCase(), "", null, 1, "general"])
+ expect(
+ landingInfo({
+ supported_extensions: ["buzz-landing-channel"],
+ landing_channel,
+ }),
+ ).toEqual({ landingChannels: true });
+ });
+});
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
new file mode 100644
index 0000000000..c108de6e72
--- /dev/null
+++ b/src/features/relay/landing.ts
@@ -0,0 +1,62 @@
+/** The relay advertises the welcome-channel command in NIP-11 under this name. */
+export const LANDING_EXTENSION = "buzz-landing-channel";
+/** Relay limit for a custom welcome message, in Unicode scalar values. */
+export const WELCOME_MAX_LENGTH = 1000;
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+/**
+ * Projects the welcome-channel tags from relay-signed channel metadata. A
+ * `welcome` tag counts only while `landing` designates the channel; a blank or
+ * repeated message falls back to the default copy.
+ */
+export function landingTags(tags: readonly (readonly string[])[]): {
+ landing?: true;
+ welcome?: string;
+} {
+ if (!tags.some(([name]) => name === "landing")) return {};
+ const welcome = tags.filter(([name]) => name === "welcome");
+ const text = welcome.length === 1 ? welcome[0]?.[1] : undefined;
+ return text !== undefined && !welcomeTextProblem(text)
+ ? { landing: true, welcome: text }
+ : { landing: true };
+}
+
+/** Line endings normalize to `\n`; surrounding whitespace is not sent. */
+export function normalizeWelcome(text: string) {
+ return text.replace(/\r\n?/g, "\n").trim();
+}
+
+/** Mirrors the relay's message rules for a normalized, non-default message. */
+export function welcomeTextProblem(
+ text: string,
+): "empty" | "long" | "control" | undefined {
+ if (!text.trim()) return "empty";
+ if ([...text].length > WELCOME_MAX_LENGTH) return "long";
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(text)) return "control";
+ return undefined;
+}
+
+/** The command's message is either the default marker `""` or a valid text. */
+export function validWelcomeCommand(text: string) {
+ return text === "" || !welcomeTextProblem(text);
+}
+
+/** Relay support and its currently designated channel, from NIP-11. */
+export function landingInfo(info: unknown): {
+ landingChannels: boolean;
+ landingChannel?: string;
+} {
+ const record =
+ info && typeof info === "object"
+ ? (info as Record)
+ : undefined;
+ const extensions = record?.supported_extensions;
+ const supported =
+ Array.isArray(extensions) && extensions.includes(LANDING_EXTENSION);
+ const channel = record?.landing_channel;
+ return supported && typeof channel === "string" && UUID.test(channel)
+ ? { landingChannels: true, landingChannel: channel }
+ : { landingChannels: supported };
+}
diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts
index 5569ba0af9..9665ccfe1d 100644
--- a/src/features/relay/store.test.ts
+++ b/src/features/relay/store.test.ts
@@ -120,6 +120,31 @@ describe("channel store", () => {
expect(queries.list().channels[0]?.autoJoin).toBe(true);
store.dispose();
});
+ it("replaces a channel summary when only its welcome state changes", async () => {
+ const { store, queries, next } = setup();
+ queries.ensureList();
+ next().respond([
+ roster(relay, "work", [viewer.pubkey]),
+ metadata(relay, "work", "Work", 1_700_000_000, [["landing"]]),
+ ]);
+ await flush();
+ const landing = queries.list().channels[0];
+ expect(landing).toMatchObject({ landing: true });
+ expect(landing?.welcome).toBeUndefined();
+
+ queries.refreshList?.();
+ next().respond([
+ roster(relay, "work", [viewer.pubkey]),
+ metadata(relay, "work", "Work", 1_700_000_001, [
+ ["landing"],
+ ["welcome", "Hello"],
+ ]),
+ ]);
+ await flush();
+ expect(queries.list().channels[0]).not.toBe(landing);
+ expect(queries.list().channels[0]?.welcome).toBe("Hello");
+ store.dispose();
+ });
it("preserves summary identity on preview refreshes and detects same-length DM roster changes", () => {
const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
let live!: LiveCallbacks;
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index dbf21bed1c..d4377c72b8 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -449,6 +449,8 @@ export function createChannelStore(
old.hidden === channel.hidden &&
old.private === channel.private &&
old.autoJoin === channel.autoJoin &&
+ old.landing === channel.landing &&
+ old.welcome === channel.welcome &&
old.channelType === channel.channelType &&
old.parentChannelId === channel.parentChannelId &&
old.huddle === channel.huddle &&
From 050c67477f82621f4edeb7b8537db935911bcb37 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 02:47:18 +1100
Subject: [PATCH 02/28] fix(communities): check an unconfirmed welcome save,
hold the reader through the card, wait for enrollment
Review follow-up for the welcome channel.
An unconfirmed welcome save no longer invites a blind republish: the
form keeps the draft, disables Save and offers Check again, which
re-reads NIP-11 and the strong 39000. A change the relay shows clears
the draft; a missing one reopens Save.
The timeline keeps a settled, non-following reader's rows in place when
the welcome card enters or leaves the measured edge, by correcting
scrollTop by the edge delta before paint.
An empty first channel list keeps the opening state for up to 10 s per
workspace, since enrollment can finish after the list answers; a
channel that arrives opens at once. Try again re-reads the list with a
strong read instead of reconnecting a healthy session, and waits again.
The blank-message check uses Unicode White_Space, matching the relay's
str::trim on U+FEFF. Adds the binding tests the review listed.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 2 +-
docs/channels.md | 12 +-
docs/communities.md | 3 +-
.../channels/ChannelsPage.tabs.test.tsx | 67 ++++++++--
src/bundled/channels/ChannelsPage.tsx | 33 ++++-
.../moderation/WelcomeSettings.module.css | 4 +
.../moderation/WelcomeSettings.test.tsx | 121 ++++++++++++++++--
src/bundled/moderation/WelcomeSettings.tsx | 70 +++++++---
.../messages/ChannelTimeline.test.tsx | 69 ++++++++--
src/features/messages/ChannelTimeline.tsx | 6 +
.../ChannelTimeline.viewport.test.tsx | 29 +++++
src/features/relay/landing.test.ts | 3 +
src/features/relay/landing.ts | 4 +-
13 files changed, 365 insertions(+), 58 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 2a36cd827a..86d25a3845 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -467,7 +467,7 @@ revokes all of them, and a pairing unused for 30 days expires.
welcome card for everyone, showing the community's message or a default one.
The card isn't a message: it can't be replied to, deleted or dismissed. While
a new member's channels arrive, Messages says it is opening them. If none
- arrive, **Try again** reconnects. Owners and admins choose the channel and edit
+ arrive within a few seconds, **Try again** checks for them again. Owners and admins choose the channel and edit
the message, up to 1,000 characters of plain text, in **Settings → Community
admin → Welcome**. That card appears only when the relay supports a welcome
channel. People already in the community keep their channels when the welcome
diff --git a/docs/channels.md b/docs/channels.md
index c3d9546f09..f3c9b5cd86 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -588,7 +588,9 @@ strong reread of the target's `39000`: the relay advertises a bare `landing` tag
there while the channel is designated, plus a `welcome` tag when the message is
custom. A `welcome` tag without `landing` is ignored. The relay's `invalid:`
reason is shown with the draft kept. When the result can't be confirmed, the
-card asks the admin to save again.
+card keeps the draft, disables Save and offers **Check again**, which reads the
+designation and message from the relay afresh instead of republishing; a change
+that did land leaves nothing to save, while a missing one reopens Save.
A published lifecycle command keeps its bounded, fresh, strong confirmation
when access loss retires access-dependent work. Retired confirmation reads
are reread; caller cancellation, session clear/disposal and the original deadline
@@ -1314,9 +1316,11 @@ for every viewer, even with no messages: the community name and its custom
message, or the default one. The card is not a message, so it has no unread
state, actions or dismissal. While the channel list or its metadata is still
loading, Messages shows **Opening your channels…** instead of the blank
-selection view. If the list loads with no channels, an empty state offers
-**Try again**, which reconnects so the relay can retry adding the person. The
-client never joins the welcome channel for them. The relay adds people to its
+selection view, and keeps showing it for up to ten seconds after a list that
+arrives empty, since the relay can still be adding a new member to the welcome
+channel; a channel that arrives meanwhile opens at once. After that wait, an
+empty state offers **Try again**, which re-reads the channel list and waits the
+same way again. The client never joins the welcome channel for them. The relay adds people to its
welcome and `auto_join` channels itself, welcome channel first, and the
resulting membership hints populate the sidebar live:
diff --git a/docs/communities.md b/docs/communities.md
index ce52951ffb..0f8dc9c58d 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -187,7 +187,8 @@ sending. A message matching the default is saved as the default rather than as
custom text. **Restore default message** changes only the draft. A preview shows
the card as members will see it. Save sends the whole state and confirms it as
described in [channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
-its reason and the draft is kept. Archive, Delete, Make private and expiry are
+its reason and the draft is kept. A save the relay has not shown yet keeps the
+draft with Save disabled until **Check again** reads the relay's state. Archive, Delete, Make private and expiry are
not disabled for the welcome channel yet; the relay refuses them, and that
refusal is shown like any other lifecycle error.
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 2663240e9b..1883e76399 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -519,18 +519,69 @@ it("falls back when the viewer is not in the welcome channel", async () => {
await screen.findByRole("textbox", { name: "Message #Alpha" });
});
-it("offers a reconnect instead of a blank view when the viewer has no channels", async () => {
- const { owner } = workspace([]);
+it("keeps opening after an empty first list, then offers a list refresh instead of a blank view", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ const viewer = keypair(),
+ authority = keypair();
+ const events: RelayEvent[] = [];
+ const { owner } = workspace(events, viewer, authority);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ // The relay may still be adding this new member to the welcome channel.
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ expect(
+ screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
+ ).toBeNull();
+ await act(() => vi.advanceTimersByTimeAsync(9_000));
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ await act(() => vi.advanceTimersByTimeAsync(1_000));
+ expect(
+ screen.getByRole("heading", { name: "You’re not in any channels yet" }),
+ ).toBeVisible();
+ expect(screen.queryByText("Select a channel to read it.")).toBeNull();
+ // Try again re-reads the list rather than reconnecting a healthy session,
+ // and waits for it the same bounded way.
+ events.push(
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(
+ screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
+ ).toBeNull();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+it("opens the welcome channel as soon as it arrives while the opening state waits", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ const events: RelayEvent[] = [];
+ const { owner } = workspace(events, viewer, authority);
await vi.waitFor(() =>
expect(owner.session.channels.list().status).toBe("ready"),
);
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ events.push(
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ );
+ owner.session.channels.refreshList?.();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
expect(
- await screen.findByRole("heading", {
- name: "You’re not in any channels yet",
- }),
- ).toBeVisible();
- expect(screen.queryByText("Select a channel to read it.")).toBeNull();
- expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled();
+ screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
+ ).toBeNull();
});
it("hands the community roster to Channel settings and follows the republished flag", async () => {
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index aa7160b53e..47cbcd0fe1 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -336,6 +336,9 @@ function LiveNewMessage(
return ;
}
+/** How long an empty first channel list keeps the opening state. */
+const ENROLLMENT_WAIT_MS = 10_000;
+
function ChannelWorkspace({
extensions,
chatMenus,
@@ -595,14 +598,31 @@ function ChannelWorkspace({
// With nothing requested, the default channel is still arriving rather than
// unchosen: a new member's welcome channel can follow the first list.
const defaulting = !emptyDestination && !requestedChannel && !current;
- const opening =
- defaulting &&
- (list.status === "idle" || list.status === "loading" || metadataPending);
- const noChannels =
+ const emptyList =
defaulting &&
list.status === "ready" &&
!metadataPending &&
!channels.some((channel) => channel.channelType !== "solo");
+ // The relay adds a new member to the welcome channel after their first list
+ // can already have answered empty. Keep opening for a bounded time before
+ // offering recovery; Try again refreshes the list and waits once more.
+ const [listAttempt, setListAttempt] = useState(0);
+ const [waitedOut, setWaitedOut] = useState(-1);
+ const noChannels = emptyList && waitedOut === listAttempt;
+ useEffect(() => {
+ if (!emptyList || waitedOut === listAttempt) return;
+ const timer = setTimeout(
+ () => setWaitedOut(listAttempt),
+ ENROLLMENT_WAIT_MS,
+ );
+ return () => clearTimeout(timer);
+ }, [emptyList, listAttempt, waitedOut]);
+ const opening =
+ defaulting &&
+ (list.status === "idle" ||
+ list.status === "loading" ||
+ metadataPending ||
+ (emptyList && !noChannels));
useLayoutEffect(() => {
// An open draft resolves nothing; keep the choice for when it closes.
if (requestedDestination) chosen.current = requestedDestination.id;
@@ -2093,7 +2113,10 @@ function ChannelWorkspace({
relay.retry()}
+ onClick={() => {
+ setListAttempt((attempt) => attempt + 1);
+ queries.channels.refreshList?.({ consistency: "strong" });
+ }}
>
Try again
diff --git a/src/bundled/moderation/WelcomeSettings.module.css b/src/bundled/moderation/WelcomeSettings.module.css
index 8232ff9744..2bb924413b 100644
--- a/src/bundled/moderation/WelcomeSettings.module.css
+++ b/src/bundled/moderation/WelcomeSettings.module.css
@@ -16,3 +16,7 @@
gap: var(--space-3);
min-width: 0;
}
+.unconfirmed {
+ display: grid;
+ gap: var(--space-2);
+}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index ca72aac5e3..121042d6ca 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import {
+ act,
cleanup,
fireEvent,
render,
@@ -17,6 +18,7 @@ import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard"
import {
ChannelLifecycleUnconfirmed,
type ChannelLanding,
+ type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
import { PublishRejected } from "../../features/relay/outbox";
import type { RelayData } from "../../features/relay/service";
@@ -61,7 +63,9 @@ function setup({
landing?: ChannelLanding | undefined;
} = {}) {
const readLanding = vi.fn(async () => landing);
- const setLanding = vi.fn(async () => {});
+ const setLanding = vi.fn(
+ async () => {},
+ );
const session = {
channelLifecycle: { available: true, readLanding, setLanding },
channels: {
@@ -73,19 +77,30 @@ function setup({
directoryComplete: () => true,
},
} as unknown as RelaySession;
- const snapshot = {
+ let snapshot = {
status: "ready",
generation: 1,
scope: `https://primary.example:${owner}`,
viewer: owner,
session,
};
+ const listeners = new Set<() => void>();
const relay = {
snapshot: () => snapshot,
- subscribe: () => () => {},
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
} as unknown as RelayData;
+ /** The same community reconnects: a new session generation. */
+ const reconnect = () => {
+ snapshot = { ...snapshot, generation: snapshot.generation + 1 };
+ act(() => {
+ for (const listener of listeners) listener();
+ });
+ };
vi.mocked(communityRequest).mockResolvedValue(info);
- render(
+ const view = render(
,
{ wrapper: ToastProvider },
);
- return { readLanding, setLanding, user: userEvent.setup() };
+ return { readLanding, setLanding, reconnect, view, user: userEvent.setup() };
}
const channel = () => screen.getByRole("combobox", { name: "Welcome channel" });
@@ -221,18 +236,102 @@ it("shows the relay's reason and keeps the draft when it refuses", async () => {
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
-it("asks to save again when the relay's change is not confirmed", async () => {
- const { setLanding, user } = setup();
- setLanding.mockRejectedValueOnce(
+/** Saves, but the relay never showed the result within the readback window. */
+async function unconfirmedSave(fixture: ReturnType) {
+ fixture.setLanding.mockRejectedValueOnce(
new ChannelLifecycleUnconfirmed(new Error("timeout")),
);
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
fireEvent.change(messageField(), { target: { value: "Say hello" } });
- await user.click(screen.getByRole("button", { name: "Save" }));
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Saved, but not confirmed yet. Save again to check.",
+ await fixture.user.click(screen.getByRole("button", { name: "Save" }));
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Not confirmed yet");
+ expect(alert).not.toHaveTextContent("Saved");
+ // The draft stays, but another save would republish blind.
+ expect(messageField()).toHaveValue("Say hello");
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+}
+
+it("settles an unconfirmed save by reading again, clearing a draft the relay shows", async () => {
+ const fixture = setup();
+ const { readLanding, setLanding, user } = fixture;
+ await unconfirmedSave(fixture);
+ readLanding.mockResolvedValue({
+ landing: true,
+ welcome: "Say hello",
+ name: "Lobby",
+ });
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull(),
);
+ expect(readLanding).toHaveBeenCalledTimes(2);
+ expect(setLanding).toHaveBeenCalledTimes(1);
expect(messageField()).toHaveValue("Say hello");
+ expect(screen.queryByRole("alert")).toBeNull();
+});
+
+it("keeps the draft and reopens Save when the check finds the change missing", async () => {
+ const fixture = setup();
+ const { readLanding, setLanding, user } = fixture;
+ await unconfirmedSave(fixture);
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(),
+ );
+ expect(readLanding).toHaveBeenCalledTimes(2);
+ expect(setLanding).toHaveBeenCalledTimes(1);
+ expect(messageField()).toHaveValue("Say hello");
+ expect(screen.queryByRole("alert")).toBeNull();
+});
+
+it("shows Save only for a change, and Cancel returns to the published state", async () => {
+ const { setLanding, user } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Hi there!" } });
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ // Typing back to the published text leaves nothing to save.
+ fireEvent.change(messageField(), { target: { value: "Hi there" } });
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(messageField(), { target: { value: "Draft" } });
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(messageField()).toHaveValue("Hi there");
+ expect(channel()).toHaveValue("Lobby");
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(setLanding).not.toHaveBeenCalled();
+});
+
+it("starts over from the relay when the community's session changes", async () => {
+ const { readLanding, reconnect } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Draft" } });
+ reconnect();
+ await waitFor(() => expect(readLanding).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+});
+
+it("abandons a save that is still in flight when the form closes", async () => {
+ const { setLanding, user, view } = setup();
+ let settle!: () => void;
+ setLanding.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => expect(setLanding).toHaveBeenCalledTimes(1));
+ const [, , signal, active] = setLanding.mock.calls[0] ?? [];
+ expect(active?.()).toBe(true);
+ view.unmount();
+ // The lifecycle owner checks this guard right before publishing.
+ expect(signal?.aborted).toBe(true);
+ expect(active?.()).toBe(false);
+ settle();
+ await act(async () => {});
});
it("offers a retry when the current welcome cannot be read", async () => {
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 7c84c5ac2a..0618599cee 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -34,11 +34,14 @@ const message = (reason: unknown) =>
reason instanceof Error ? reason.message : String(reason);
const MAX = WELCOME_MAX_LENGTH.toLocaleString("en-US");
-/** What a failed save means for the draft, which is always kept. */
+/** The default is never stored: a draft matching it restores the default. */
+const commandFor = (text: string) => {
+ const normalized = normalizeWelcome(text);
+ return normalized === DEFAULT_WELCOME_MESSAGE ? "" : normalized;
+};
+
+/** What a definite failure means for the draft, which is always kept. */
function saveError(error: unknown) {
- // Saving the same state again is idempotent, so saving again is the check.
- if (error instanceof ChannelLifecycleUnconfirmed)
- return "Saved, but not confirmed yet. Save again to check.";
if (error instanceof PublishNotSent) return "Not sent. Try again.";
if (error instanceof PublishRejected) {
const reason = error.reason?.replace(/^invalid:\s*/, "").trim();
@@ -119,6 +122,8 @@ function WelcomeForm({
const [typed, setTyped] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
+ // An unconfirmed publication: only a fresh read, never a repeat, settles it.
+ const [unconfirmed, setUnconfirmed] = useState(false);
const [saved, setSaved] = useState(false);
const mounted = useRef(true);
const saveController = useRef(undefined);
@@ -152,14 +157,21 @@ function WelcomeForm({
? await lifecycle.readLanding(id, reading.signal)
: undefined;
reading.signal.throwIfAborted();
- setLoad({
- status: "ready",
- published: {
- ...(id && state ? { channelId: id } : {}),
- ...(state?.name ? { name: state.name } : {}),
- welcome: (state?.landing && state.welcome) || "",
- },
- });
+ const published: Published = {
+ ...(id && state ? { channelId: id } : {}),
+ ...(state?.name ? { name: state.name } : {}),
+ welcome: (state?.landing && state.welcome) || "",
+ };
+ setLoad({ status: "ready", published });
+ // A draft the relay now shows has landed; there is nothing to save.
+ setDraft((current) =>
+ current &&
+ current.channelId === published.channelId &&
+ commandFor(current.text) === published.welcome
+ ? undefined
+ : current,
+ );
+ setUnconfirmed(false);
} catch (reason) {
if (!reading.signal.aborted)
setLoad({ status: "error", error: message(reason) });
@@ -185,8 +197,7 @@ function WelcomeForm({
const channelId = draft ? draft.channelId : published?.channelId;
const text = draft?.text ?? (published?.welcome || DEFAULT_WELCOME_MESSAGE);
const normalized = normalizeWelcome(text);
- // The default is never stored: matching it restores the default.
- const command = normalized === DEFAULT_WELCOME_MESSAGE ? "" : normalized;
+ const command = commandFor(text);
const problem = welcomeTextProblem(normalized);
const dirty =
!!published &&
@@ -278,8 +289,10 @@ function WelcomeForm({
setDraft(undefined);
setSaved(true);
} catch (reason) {
- if (mounted.current && !owner.signal.aborted)
- setError(saveError(reason));
+ if (!mounted.current || owner.signal.aborted) return;
+ if (reason instanceof ChannelLifecycleUnconfirmed)
+ setUnconfirmed(true);
+ else setError(saveError(reason));
} finally {
if (mounted.current) setSaving(false);
}
@@ -364,6 +377,23 @@ function WelcomeForm({
{error}
)}
+ {unconfirmed && (
+
+
+ Not confirmed yet. The relay hasn’t shown this change; check
+ before saving again.
+
+
+ setAttempt((n) => n + 1)}
+ >
+ Check again
+
+
+
+ )}
{draft && (
Save
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index f8ca1913f8..7161028434 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -1,6 +1,6 @@
import { Button } from "../../shared/design-system/ui/Button";
import { afterEach, expect, it, vi } from "vitest";
-import type { ReactElement } from "react";
+import type { ReactElement, ReactNode } from "react";
import { Virtualizer } from "virtua";
import { ChannelTimeline } from "./ChannelTimeline";
import { JumpToLatestButton } from "./JumpToLatestButton";
@@ -139,6 +139,7 @@ function setup({
initial = undefined as unknown,
mounted = [] as { id: string; y: number }[],
initialRows = undefined as ChannelMessage[] | undefined,
+ start = undefined as ReactNode,
} = {}) {
Object.assign(hooks, {
refs: [],
@@ -217,6 +218,14 @@ function setup({
]);
}
};
+ // The measured edge above the rows: its height is Virtua's start margin.
+ let edgeHeight = 0;
+ const edgeElement = {
+ get clientWidth() {
+ return element.clientWidth;
+ },
+ getBoundingClientRect: () => ({ height: edgeHeight }),
+ };
const element = {
clientWidth: 1124,
clientHeight: 668,
@@ -328,6 +337,7 @@ function setup({
},
onOpenLink: () => false,
revealMessageId,
+ start,
});
if (key !== null && key !== scoped.key) {
for (const effect of hooks.effects) effect.cleanup?.();
@@ -344,15 +354,9 @@ function setup({
scoped.props,
);
section.props.ref.current = element;
- const edge = section.props.children[0] as ReactElement<{
- ref: { current: unknown };
- }>;
- edge.props.ref.current = {
- get clientWidth() {
- return element.clientWidth;
- },
- getBoundingClientRect: () => ({ height: 0 }),
- };
+ (
+ section.props.children[0] as ReactElement<{ ref: { current: unknown } }>
+ ).props.ref.current = edgeElement;
const virtualizer = section.props.children.find(
(child) =>
!!child &&
@@ -470,6 +474,18 @@ function setup({
resized(element);
render(runFrames);
},
+ /** The edge laid out at a new height, as start content enters or leaves. */
+ edge(height: number, runFrames = true) {
+ edgeHeight = height;
+ resized(edgeElement);
+ render(runFrames);
+ },
+ hasStart() {
+ const edge = section.props.children[0] as ReactElement<{
+ children: unknown[];
+ }>;
+ return edge.props.children.includes(start);
+ },
measureRows(runFrames = true) {
list.style.height = `${Number.parseFloat(list.style.height) + 100}px`;
styleChanged();
@@ -707,6 +723,39 @@ it.each([
expect(h.loadOlder).not.toHaveBeenCalled();
h.unmount();
});
+it("shows the start content only once the oldest history is loaded", () => {
+ const h = setup({ hasMore: true, start: "Welcome" });
+ expect(h.hasStart()).toBe(false);
+ h.update({ hasMore: false });
+ expect(h.hasStart()).toBe(true);
+ h.update({ hasMore: true, loadingOlder: false });
+ expect(h.hasStart()).toBe(false);
+ h.unmount();
+});
+it("keeps a settled reader's rows in place as start content enters and leaves the edge", () => {
+ const h = setup({ hasMore: true, start: "Welcome" });
+ h.element.scrollTop = 1200;
+ h.scroll();
+ h.gesture(true);
+ // The last older page lands and the welcome enters the edge above the rows.
+ h.update({ hasMore: false });
+ h.edge(140);
+ expect(h.element.scrollTop).toBe(1340);
+ // Trimming the oldest page reopens history and the welcome leaves again.
+ h.update({ hasMore: true });
+ h.edge(0);
+ expect(h.element.scrollTop).toBe(1200);
+ h.unmount();
+});
+it("leaves a following reader at the bottom when start content enters the edge", () => {
+ const h = setup({ hasMore: true, start: "Welcome" });
+ h.scroll(false);
+ const before = h.element.scrollTop;
+ h.update({ hasMore: false });
+ h.edge(140);
+ expect(h.element.scrollTop).toBe(before);
+ h.unmount();
+});
it("programmatic scroll does not page before a user gesture", () => {
const h = setup({ hasMore: true });
h.scroll(false);
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index fce92ea073..d542d181bd 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -1393,6 +1393,12 @@ function Timeline({
next.edgeHeight === measuredSize.edgeHeight
)
return;
+ // Start content entering or leaving the edge moves every row below it
+ // before Virtua learns the new start margin. Keep a settled reader's
+ // rows where they were; a following reader is pinned to the bottom.
+ const edgeDelta = next.edgeHeight - measuredSize.edgeHeight;
+ if (edgeDelta && settled.current && !follow.current)
+ correctScrollTop(element, edgeDelta);
// A growing composer shrinks the viewport. Pin a reader who was at the
// bottom before paint so history rises with it; the restore lands later.
if (
diff --git a/src/features/messages/ChannelTimeline.viewport.test.tsx b/src/features/messages/ChannelTimeline.viewport.test.tsx
index e60d9a983a..86c1220e55 100644
--- a/src/features/messages/ChannelTimeline.viewport.test.tsx
+++ b/src/features/messages/ChannelTimeline.viewport.test.tsx
@@ -242,6 +242,35 @@ it("shows one latest-agent preview, retains expanded history through appends, an
}
});
+it("renders the start content in the edge only once there is no older history", () => {
+ const { owner, updates, content } = activityFixture();
+ try {
+ const timeline = (hasMore: boolean) =>
+ cloneElement(content(updates(2)), {
+ start:
Welcome aboard
,
+ window: {
+ channelId: "alpha",
+ rows: updates(2),
+ status: "ready",
+ hasMore,
+ loadingOlder: false,
+ freshness: "verified",
+ error: undefined,
+ },
+ });
+ const view = render(timeline(true));
+ expect(screen.queryByText("Welcome aboard")).toBeNull();
+ view.rerender(timeline(false));
+ const welcome = screen.getByText("Welcome aboard");
+ expect(welcome).toBeVisible();
+ expect(welcome.closest("[data-start]")).not.toBeNull();
+ view.rerender(timeline(true));
+ expect(screen.queryByText("Welcome aboard")).toBeNull();
+ } finally {
+ owner.dispose();
+ }
+});
+
it("shows the first three distinct agents in the visible identity cluster while retaining its full label", () => {
const { owner, updates, content } = activityFixture();
try {
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
index ee35e0a704..10e9b03a4d 100644
--- a/src/features/relay/landing.test.ts
+++ b/src/features/relay/landing.test.ts
@@ -45,6 +45,9 @@ describe("welcome text", () => {
expect(welcomeTextProblem("😀".repeat(1000))).toBeUndefined();
expect(welcomeTextProblem("a".repeat(1001))).toBe("long");
expect(welcomeTextProblem(" \n ")).toBe("empty");
+ // U+FEFF is not Unicode White_Space: the relay keeps it, so both hosts do.
+ expect(welcomeTextProblem("\uFEFF")).toBeUndefined();
+ expect(welcomeTextProblem("\u2003\u3000")).toBe("empty");
expect(welcomeTextProblem("line\nbreak")).toBeUndefined();
for (const text of ["tab\t", "cr\r", "nul\u0000", "del\u007f", "c1\u0085"])
expect(welcomeTextProblem(text)).toBe("control");
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
index c108de6e72..6acbeb8f83 100644
--- a/src/features/relay/landing.ts
+++ b/src/features/relay/landing.ts
@@ -31,7 +31,9 @@ export function normalizeWelcome(text: string) {
export function welcomeTextProblem(
text: string,
): "empty" | "long" | "control" | undefined {
- if (!text.trim()) return "empty";
+ // Unicode White_Space, as the relay's `str::trim` sees it: unlike `trim()`
+ // it keeps U+FEFF, so a message of only that passes both hosts alike.
+ if (/^\p{White_Space}*$/u.test(text)) return "empty";
if ([...text].length > WELCOME_MAX_LENGTH) return "long";
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(text)) return "control";
From b9c9a1f68b318e4f9564a1c7043334aaca2beec1 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 03:00:57 +1100
Subject: [PATCH 03/28] fix(communities): re-record the reader after the
welcome edge correction; keep the form during Check again
Second-round review follow-up for the welcome channel.
After correcting scrollTop for start content entering or leaving the
timeline edge, measure() re-records the saved position from the
corrected place (unless a restore is pending), so the restore the size
change schedules returns there instead of to the position a shift
scroll event recorded after the edge had moved the rows. The harness
test now models that scroll event with row geometry that follows the
offset, and asserts the restore targets the corrected anchor.
Check again keeps the published form visible and the button focused
(busy) while the relay is re-read; a check that finds the change landed
shows the Welcome saved toast and returns focus to the message field.
The negative assertion in its test now follows a positive barrier.
Try again moves focus to the history pane before the button leaves. The
wait comment and docs now say any empty default waits once per visit.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 11 +++--
.../channels/ChannelsPage.tabs.test.tsx | 5 +-
src/bundled/channels/ChannelsPage.tsx | 12 +++--
.../moderation/WelcomeSettings.test.tsx | 34 +++++++++++---
src/bundled/moderation/WelcomeSettings.tsx | 46 +++++++++++++-----
.../messages/ChannelTimeline.test.tsx | 47 ++++++++++++++++---
src/features/messages/ChannelTimeline.tsx | 10 +++-
7 files changed, 128 insertions(+), 37 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index f3c9b5cd86..226e193bb9 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1316,11 +1316,12 @@ for every viewer, even with no messages: the community name and its custom
message, or the default one. The card is not a message, so it has no unread
state, actions or dismissal. While the channel list or its metadata is still
loading, Messages shows **Opening your channels…** instead of the blank
-selection view, and keeps showing it for up to ten seconds after a list that
-arrives empty, since the relay can still be adding a new member to the welcome
-channel; a channel that arrives meanwhile opens at once. After that wait, an
-empty state offers **Try again**, which re-reads the channel list and waits the
-same way again. The client never joins the welcome channel for them. The relay adds people to its
+selection view. A list with no channel to open keeps that state for up to ten
+seconds, once per community visit, since the relay can still be adding a new
+member to the welcome channel; a channel that arrives meanwhile opens at once.
+A returning member with no channels left waits the same once. After that wait,
+an empty state offers **Try again**, which re-reads the channel list and waits
+the same way again. The client never joins the welcome channel for them. The relay adds people to its
welcome and `auto_join` channels itself, welcome channel first, and the
resulting membership hints populate the sidebar live:
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 1883e76399..7427a81340 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -550,10 +550,13 @@ it("keeps opening after an empty first list, then offers a list refresh instead
["landing"],
]),
);
- fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ const tryAgain = screen.getByRole("button", { name: "Try again" });
+ tryAgain.focus();
+ fireEvent.click(tryAgain);
expect(
screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
).toBeNull();
+ expect(screen.getByRole("region", { name: "Channel history" })).toHaveFocus();
await screen.findByRole("textbox", { name: "Message #Beta" });
} finally {
vi.useRealTimers();
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 47cbcd0fe1..32dd31ef22 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -336,7 +336,7 @@ function LiveNewMessage(
return ;
}
-/** How long an empty first channel list keeps the opening state. */
+/** How long a channel list with nothing to open keeps the opening state. */
const ENROLLMENT_WAIT_MS = 10_000;
function ChannelWorkspace({
@@ -603,9 +603,10 @@ function ChannelWorkspace({
list.status === "ready" &&
!metadataPending &&
!channels.some((channel) => channel.channelType !== "solo");
- // The relay adds a new member to the welcome channel after their first list
- // can already have answered empty. Keep opening for a bounded time before
- // offering recovery; Try again refreshes the list and waits once more.
+ // A list with nothing to open can be a new member's, read before the relay
+ // has added them to the welcome channel. Any such empty default keeps
+ // opening for a bounded time, once per workspace, before offering recovery;
+ // Try again refreshes the list and waits once more.
const [listAttempt, setListAttempt] = useState(0);
const [waitedOut, setWaitedOut] = useState(-1);
const noChannels = emptyList && waitedOut === listAttempt;
@@ -2114,6 +2115,9 @@ function ChannelWorkspace({
type="button"
variant="primary"
onClick={() => {
+ // This button leaves with the empty state; keep focus in
+ // the pane that announces the wait.
+ historyContainer.current?.focus({ preventScroll: true });
setListAttempt((attempt) => attempt + 1);
queries.channels.refreshList?.({ consistency: "strong" });
}}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 121042d6ca..bce47fbd0b 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -262,27 +262,47 @@ it("settles an unconfirmed save by reading again, clearing a draft the relay sho
name: "Lobby",
});
await user.click(screen.getByRole("button", { name: "Check again" }));
- await waitFor(() =>
- expect(screen.queryByRole("button", { name: "Save" })).toBeNull(),
- );
+ // The confirmed outcome is announced like a direct save.
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
expect(readLanding).toHaveBeenCalledTimes(2);
expect(setLanding).toHaveBeenCalledTimes(1);
expect(messageField()).toHaveValue("Say hello");
- expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.queryByText(/Not confirmed yet/)).toBeNull();
});
-it("keeps the draft and reopens Save when the check finds the change missing", async () => {
+it("keeps the draft in view during the check and reopens Save when the change is missing", async () => {
const fixture = setup();
const { readLanding, setLanding, user } = fixture;
await unconfirmedSave(fixture);
+ let release!: (state: ChannelLanding) => void;
+ readLanding.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
await user.click(screen.getByRole("button", { name: "Check again" }));
+ try {
+ // The published form stays in view while the relay is read again.
+ expect(readLanding).toHaveBeenCalledTimes(2);
+ expect(messageField()).toBeVisible();
+ expect(messageField()).toHaveValue("Say hello");
+ const check = screen.getByRole("button", { name: "Check again" });
+ expect(check).toHaveAttribute("aria-busy", "true");
+ expect(check).toHaveFocus();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ } finally {
+ release({ landing: true, welcome: "Hi there", name: "Lobby" });
+ }
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(),
);
- expect(readLanding).toHaveBeenCalledTimes(2);
expect(setLanding).toHaveBeenCalledTimes(1);
expect(messageField()).toHaveValue("Say hello");
- expect(screen.queryByRole("alert")).toBeNull();
+ expect(messageField()).toHaveFocus();
+ expect(screen.queryByText(/Not confirmed yet/)).toBeNull();
+ expect(screen.queryByText("Welcome saved")).toBeNull();
});
it("shows Save only for a change, and Cancel returns to the published state", async () => {
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 0618599cee..5a913a5f81 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -124,7 +124,13 @@ function WelcomeForm({
const [error, setError] = useState("");
// An unconfirmed publication: only a fresh read, never a repeat, settles it.
const [unconfirmed, setUnconfirmed] = useState(false);
+ const [checking, setChecking] = useState(false);
const [saved, setSaved] = useState(false);
+ // The check compares the relay's answer with the draft as it stands then.
+ const draftRef = useRef(draft);
+ draftRef.current = draft;
+ const checkButton = useRef(null);
+ const messageInput = useRef(null);
const mounted = useRef(true);
const saveController = useRef(undefined);
useEffect(() => {
@@ -138,7 +144,11 @@ function WelcomeForm({
// biome-ignore lint/correctness/useExhaustiveDependencies: An explicit retry reruns this finite read.
useEffect(() => {
const reading = new AbortController();
- setLoad({ status: "loading" });
+ // A check keeps the published form in view; only the first read waits.
+ const check = attempt > 0;
+ setLoad((current) =>
+ check && current.status === "ready" ? current : { status: "loading" },
+ );
void (async () => {
try {
const info = await communityRequest(
@@ -162,19 +172,26 @@ function WelcomeForm({
...(state?.name ? { name: state.name } : {}),
welcome: (state?.landing && state.welcome) || "",
};
- setLoad({ status: "ready", published });
// A draft the relay now shows has landed; there is nothing to save.
- setDraft((current) =>
- current &&
+ const current = draftRef.current;
+ const landed =
+ !!current &&
current.channelId === published.channelId &&
- commandFor(current.text) === published.welcome
- ? undefined
- : current,
- );
+ commandFor(current.text) === published.welcome;
+ setLoad({ status: "ready", published });
+ if (landed) {
+ setDraft(undefined);
+ setSaved(true);
+ }
setUnconfirmed(false);
+ setChecking(false);
+ // The Check again button leaves with its notice; stay in the form.
+ if (check && document.activeElement === checkButton.current)
+ messageInput.current?.focus({ preventScroll: true });
} catch (reason) {
- if (!reading.signal.aborted)
- setLoad({ status: "error", error: message(reason) });
+ if (reading.signal.aborted) return;
+ setChecking(false);
+ setLoad({ status: "error", error: message(reason) });
}
})();
return () => reading.abort();
@@ -354,6 +371,7 @@ function WelcomeForm({
error={problem && problems[problem]}
>
setAttempt((n) => n + 1)}
+ loading={checking}
+ onClick={() => {
+ setChecking(true);
+ setAttempt((n) => n + 1);
+ }}
>
Check again
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index 7161028434..4b1a81f625 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -237,12 +237,16 @@ function setup({
focus: vi.fn(),
getBoundingClientRect: () => ({ top: 0 }),
querySelector: () => list,
+ // Row geometry reads `y` when asked, so a test can move the rows.
querySelectorAll: () =>
- mounted.map(({ id, y }) => ({
- dataset: { messageId: id },
- getBoundingClientRect: () => ({ top: y, bottom: y + 100 }),
+ mounted.map((row) => ({
+ dataset: { messageId: row.id },
+ getBoundingClientRect: () => ({ top: row.y, bottom: row.y + 100 }),
querySelector: () => ({
- getBoundingClientRect: () => ({ top: y + 32, bottom: y + 80 }),
+ getBoundingClientRect: () => ({
+ top: row.y + 32,
+ bottom: row.y + 80,
+ }),
}),
})),
};
@@ -733,18 +737,47 @@ it("shows the start content only once the oldest history is loaded", () => {
h.unmount();
});
it("keeps a settled reader's rows in place as start content enters and leaves the edge", () => {
- const h = setup({ hasMore: true, start: "Welcome" });
- h.element.scrollTop = 1200;
+ const row = { id: "first", y: 100 };
+ const h = setup({ hasMore: true, start: "Welcome", mounted: [row] });
+ // The rows' viewport geometry follows the scroll offset, as in a browser.
+ let offset = 1200;
+ Object.defineProperty(h.element, "scrollTop", {
+ configurable: true,
+ get: () => offset,
+ set(value: number) {
+ row.y -= value - offset;
+ offset = value;
+ },
+ });
h.scroll();
h.gesture(true);
- // The last older page lands and the welcome enters the edge above the rows.
+ // The last older page lands and the welcome grows the edge above the rows,
+ // which moves them down before the shift's scroll event records a place.
h.update({ hasMore: false });
+ row.y += 140;
+ h.dispatchScroll();
+ h.handle.scrollToIndex.mockClear();
h.edge(140);
expect(h.element.scrollTop).toBe(1340);
+ expect(row.y).toBe(100);
+ // The restore that follows the size change returns to the corrected place,
+ // not to the row's position after the edge had pushed it down.
+ expect(h.handle.scrollToIndex).toHaveBeenCalledWith(0, {
+ align: "start",
+ offset: -100,
+ });
+ for (const [, options] of h.handle.scrollToIndex.mock.calls)
+ expect(options).toEqual({ align: "start", offset: -100 });
// Trimming the oldest page reopens history and the welcome leaves again.
h.update({ hasMore: true });
+ row.y -= 140;
+ h.dispatchScroll();
+ h.handle.scrollToIndex.mockClear();
h.edge(0);
expect(h.element.scrollTop).toBe(1200);
+ expect(row.y).toBe(100);
+ for (const [, options] of h.handle.scrollToIndex.mock.calls)
+ expect(options).toEqual({ align: "start", offset: -100 });
h.unmount();
});
it("leaves a following reader at the bottom when start content enters the edge", () => {
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index d542d181bd..576870f6ed 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -1397,8 +1397,16 @@ function Timeline({
// before Virtua learns the new start margin. Keep a settled reader's
// rows where they were; a following reader is pinned to the bottom.
const edgeDelta = next.edgeHeight - measuredSize.edgeHeight;
- if (edgeDelta && settled.current && !follow.current)
+ if (edgeDelta && settled.current && !follow.current) {
correctScrollTop(element, edgeDelta);
+ // The restore this size change schedules must return here, not to
+ // the place a scroll event recorded after the edge had moved the rows.
+ if (
+ !restoredAnchor.current &&
+ pendingRestore.current !== intent.current
+ )
+ savedPosition.current = { ...positionAt(element), bottom: false };
+ }
// A growing composer shrinks the viewport. Pin a reader who was at the
// bottom before paint so history rises with it; the restore lands later.
if (
From ec9a4dcb12a4196de4d3396f4b3d84cb5b16a592 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 15:37:53 +1100
Subject: [PATCH 04/28] feat(communities): titled Markdown welcome and starter
channels in Settings
Render the welcome as Markdown through MessageMarkdown in the timeline
card and the Settings preview. Identity links show as profile chips
without notifying anyone, and pasted buzz:// channel links open in
place. A non-interactive preview now shows profile links as inert chips.
Add a Title field, carried as welcome_title in the full-state 9002 and
projected from kind 39000. Raise the message limit to 4,000 scalar
values, allow tabs, and update the TS and Rust validators. Both accept
the 3-tag form, which keeps the title.
Move starter channels to Settings -> Community admin -> Welcome as
switches bound to auto_join. The welcome channel is a fixed first row,
and one change runs at a time. The list shows even on relays without
the welcome-channel extension. Owners and admins can flag channels they
have not joined, and the readback feeds discovery only for accessible
channels. Remove the per-channel switch from Channel settings along
with its membership plumbing, and point the Archive and Make private
copy to the new place.
Co-Authored-By: Claude Opus 5.5
Signed-off-by: Matt Toohey
---
browser-host/relay-broker-api.test.mjs | 15 +-
docs/buzz-help.md | 15 +-
docs/channels.md | 62 +-
docs/communities.md | 51 +-
src-tauri/src/relay/channel_writes.rs | 90 ++-
src/bundled/channels/ChannelBody.tsx | 28 +-
.../channels/ChannelGeneralAccess.test.tsx | 2 +-
src/bundled/channels/ChannelGeneralAccess.tsx | 2 +-
.../channels/ChannelLifecycleDialog.tsx | 2 +-
.../channels/ChannelSettingsPanel.test.tsx | 320 +-------
src/bundled/channels/ChannelSettingsPanel.tsx | 126 ---
src/bundled/channels/Channels.module.css | 12 -
.../channels/ChannelsPage.tabs.test.tsx | 72 +-
src/bundled/channels/ChannelsPage.tsx | 5 +-
src/bundled/moderation/README.md | 9 +-
.../moderation/WelcomeSettings.test.tsx | 290 ++++++-
src/bundled/moderation/WelcomeSettings.tsx | 754 ++++++++++++------
src/bundled/moderation/index.tsx | 32 +-
src/bundled/moderation/registration.test.ts | 30 +-
src/bundled/moderation/welcome.ts | 109 ---
.../ChannelNavigationState.test.tsx | 2 +-
.../communities/WelcomeCard.module.css | 5 +-
src/features/communities/WelcomeCard.test.tsx | 97 ++-
src/features/communities/WelcomeCard.tsx | 103 ++-
.../messages/MessageMarkdown.test.tsx | 20 +
src/features/panels/service.test.tsx | 2 +-
.../relay/channel-lifecycle-protocol.ts | 38 +-
src/features/relay/channel-lifecycle.test.ts | 185 ++++-
src/features/relay/channel-lifecycle.ts | 32 +-
src/features/relay/contracts.ts | 6 +-
src/features/relay/discovery-landing.test.ts | 6 +-
src/features/relay/landing.test.ts | 65 +-
src/features/relay/landing.ts | 63 +-
src/features/relay/store-discovery.test.ts | 7 +-
src/features/relay/store.test.ts | 19 +-
src/features/relay/store.ts | 5 +
tests/fixtures/conversation.tsx | 2 -
tests/fixtures/mentions.tsx | 2 -
38 files changed, 1550 insertions(+), 1135 deletions(-)
delete mode 100644 src/bundled/moderation/welcome.ts
diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index c8562a95f9..911ff0c71f 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -3412,7 +3412,17 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
tags: [
["h", id],
["landing", "true"],
- ["welcome", "a\tb"],
+ ["welcome", "a\rb"],
+ ],
+ },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "two\nlines"],
],
},
{
@@ -3496,7 +3506,8 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
tags: [
["h", id],
["landing", "true"],
- ["welcome", "Say hi\nhere"],
+ ["welcome", "# Say hi\n\n\t**here**"],
+ ["welcome_title", "Hello"],
],
};
expect((await h.post("sign", landing)).status).toBe(400);
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 86d25a3845..003c792d8c 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -468,10 +468,17 @@ revokes all of them, and a pairing unused for 30 days expires.
The card isn't a message: it can't be replied to, deleted or dismissed. While
a new member's channels arrive, Messages says it is opening them. If none
arrive within a few seconds, **Try again** checks for them again. Owners and admins choose the channel and edit
- the message, up to 1,000 characters of plain text, in **Settings → Community
- admin → Welcome**. That card appears only when the relay supports a welcome
- channel. People already in the community keep their channels when the welcome
- channel changes.
+ the card's title and message in **Settings → Community admin → Welcome**. The
+ message is Markdown, up to 4,000 characters; identity links name people
+ without notifying them, and channel links can be pasted from a channel's
+ **Copy link**. People already in the community keep their channels when the
+ welcome channel changes.
+- **Starter channels**: in the same **Welcome** settings, owners and admins
+ choose which other open channels new members join, even on relays without a
+ welcome channel. A change applies to people who join afterwards, not to
+ current members; in an open community it also adds returning people who were
+ never in that channel. Archiving a starter channel or making it private
+ pauses it until it is public again.
- **Finding channels**: search's **Channels** tab lists the community's open
channels you can join, with their member counts; typing also matches channels
you're in. Opening an unjoined channel shows a preview with **Join**. Private
diff --git a/docs/channels.md b/docs/channels.md
index 226e193bb9..2a07392eba 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -570,23 +570,25 @@ the returned command, and confirms relay-owned state before removing a row. Arch
confirmed Delete/Leave use the existing access-loss purge. Commands use narrow
development-broker routes or the packaged native `relay_channel_sign` and
`relay_channel_publish` commands, never the message outbox or automatic replay.
-The same route carries the **Starter channel for new members** switch as two commands
-without a confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002`
-with `auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an
-active public stream channel before signing (channel roles cannot show community
-ownership; the relay accepts the flag only from community owners and admins) and
-confirms the result from the republished `39000`: the relay advertises the flag
-as a bare `auto_join` tag only while it is in effect, so success is that tag's
-presence or absence, not the written value.
The community **Welcome** settings card ([communities](communities.md#welcome-channel))
-uses the same route for the welcome channel. Each save is one `9002` carrying the
-full state, `landing=true` plus `welcome=`, or an empty `welcome` for the
-default message; the relay accepts a repeat of the current state. Before signing
+uses the same route for its starter-channel switches, two commands without a
+confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002` with
+`auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an
+active public stream channel before signing, whether or not the viewer is in it
+(channel roles cannot show community ownership; the relay accepts the flag only
+from community owners and admins), and confirms the result from the republished
+`39000`: the relay advertises the flag as a bare `auto_join` tag only while it is
+in effect, so success is that tag's presence or absence, not the written value.
+That reread updates the channel list only for a channel the viewer can access.
+The card's welcome form uses the same route for the welcome channel. Each save is
+one `9002` carrying the full state, `landing=true`, `welcome=` and
+`welcome_title=`, each empty for its default; the relay accepts a repeat of
+the current state, and keeps the title when a `welcome_title` tag is absent. Before signing
and again before publishing, the lifecycle owner requires fresh relay metadata
for an active, open stream channel without an expiry. It confirms the save with a
strong reread of the target's `39000`: the relay advertises a bare `landing` tag
-there while the channel is designated, plus a `welcome` tag when the message is
-custom. A `welcome` tag without `landing` is ignored. The relay's `invalid:`
+there while the channel is designated, plus `welcome` and `welcome_title` tags
+while the message and title are custom. Neither tag counts without `landing`. The relay's `invalid:`
reason is shown with the draft kept. When the result can't be confirmed, the
card keeps the draft, disables Save and offers **Check again**, which reads the
designation and message from the relay afresh instead of republishing; a change
@@ -598,12 +600,13 @@ still abort its signal. A rejected publication or definitive confirmation-read
failure still fails the command; an aborted read alone never confirms it. These signals
do not forcibly interrupt an in-flight native publication IPC.
Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive),
-`archived=false` (Unarchive) or `auto_join=true|false` (the switch), and the
-three-tag welcome shape with `landing=true` and a `welcome` of 1–1,000 characters
-(no control characters except line breaks) or empty; other values,
+`archived=false` (Unarchive) or `auto_join=true|false` (a starter switch), and the
+welcome shape: `landing=true`, a `welcome` of 1–4,000 characters (no control
+characters except line breaks and tabs) or empty, then optionally a
+`welcome_title` of 1–120 characters (no control characters) or empty; other values,
extra tags, cross-route commands and
altered or foreign signatures are rejected. Hosts without this capability display
-an unavailable notice. Native Unarchive, the auto-join switch and the welcome
+an unavailable notice. Native Unarchive, the starter switches and the welcome
command require rebuilding/restarting the desktop
binary; updating the frontend alone does not update the Rust validator. Rust
validator and production-IPC tests cover both values, but do not establish an
@@ -679,23 +682,14 @@ row are omitted.
Duration uses Create's Ongoing/Temporary cards. Access controls live in the members
dialog, separate from the text and lifespan draft. These details controls stage
changes; none publishes immediately.
-**Starter channel for new members**, between Description and Members, is separate: an
-immediate switch that publishes its own narrow `9002` (see
-[Channel lifecycle](#channel-lifecycle)) and shows pending, the relay's refusal
-or, when the change is not confirmed, a prompt to try again beside itself. Once the
-channel's metadata shows the value that toggle wanted, the prompt clears, so trying
-again cannot undo a change that landed. It appears only to community owners and admins
-(the relay-signed kind `13534` roster) who are members of an active public stream
-channel, not previewing it or a guest in it, and only once the channel is loaded
-live rather than from cache. The relay checks the community role, not channel
-membership, so the gate is display-only and narrower: an owner or admin must join
-the channel to see the switch.
-Archiving a starter channel or making it private hides the switch and pauses the
-flag without clearing it: the relay stops advertising and applying it, refuses
-to change it on a private channel, and resumes adding new members on Unarchive
-or once the channel is public again. For a starter channel, the Archive and
-**Change general access?** (to Private) confirmations say so, so an owner or
-admin can turn the switch off first.
+Starter channels are chosen in **Settings → Community admin → Welcome** (see
+[communities](communities.md#welcome-channel)), not here.
+Archiving a starter channel or making it private removes it from that list and
+pauses the flag without clearing it: the relay stops advertising and applying it,
+refuses to change it on a private channel, and resumes adding new members on
+Unarchive or once the channel is public again. For a starter channel, the Archive
+and **Change general access?** (to Private) confirmations say so and point to the
+Welcome settings, so an owner or admin can turn the flag off first.
**Save changes** submits the draft together and is enabled only for valid, changed values.
**Cancel** explicitly discards edits and closes without confirmation. Close, Escape
and backdrop clicks close untouched forms immediately; changed drafts first show
diff --git a/docs/communities.md b/docs/communities.md
index 0f8dc9c58d..3be931b3e1 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -173,24 +173,41 @@ first, ahead of the starter channels. Messages opens it when there is no saved
selection, and its timeline starts with a welcome card (see
[local-first launch](channels.md#local-first-launch)).
-Owners and admins set it under **Community admin → Welcome**. The card appears
-only when the relay advertises the extension and the relay-signed roster gives
-the viewer an owner or admin role. A failed support read hides the card. Each
-time the card opens, it reads the current designation from a fresh NIP-11
-document, then reads that channel's message with a strong metadata read. If
-nothing is designated yet, the channel field starts empty and must be chosen
-before Save. The picker lists public, unarchived stream channels. The message
-editor starts with the custom text or the default, and checks the relay's rules
-as the admin types: 1–1,000 characters, and no control characters other than
-line breaks. Line endings are normalized and the message is trimmed before
-sending. A message matching the default is saved as the default rather than as
-custom text. **Restore default message** changes only the draft. A preview shows
-the card as members will see it. Save sends the whole state and confirms it as
-described in [channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
+Owners and admins set it under **Community admin → Welcome**, which appears
+when the relay-signed roster gives the viewer an owner or admin role. Each time
+the card opens, it reads a fresh NIP-11 document. On a relay without the
+extension it says so and shows only the starter channels. Otherwise it reads the
+current designation from that document, then that channel's title and message
+with a strong metadata read. If nothing is designated yet, the channel field
+starts empty and must be chosen before Save. The picker lists public, unarchived
+stream channels.
+
+The title starts with the custom title or the default, "Welcome to" and the
+community's name: 1–120 characters on one line. The message starts with the
+custom text or the default. It is Markdown, rendered as messages are:
+`[@Name](nostr:npub…)` identity links show the person without notifying them,
+and channel or message links pasted from **Copy link** open in place. It takes
+1–4,000 characters, with no control characters other than line breaks and tabs.
+Both are checked against the relay's rules as the admin types. Line endings are
+normalized and both are trimmed before sending. A title or message matching the
+default is saved as the default rather than as custom text. **Restore defaults**
+resets both in the draft. A preview shows the card as members will see it, with
+its links inert. Save sends the whole state and confirms it as described in
+[channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
its reason and the draft is kept. A save the relay has not shown yet keeps the
-draft with Save disabled until **Check again** reads the relay's state. Archive, Delete, Make private and expiry are
-not disabled for the welcome channel yet; the relay refuses them, and that
-refusal is shown like any other lifecycle error.
+draft with Save disabled until **Check again** reads the relay's state. Archive,
+Delete, Make private and expiry are not disabled for the welcome channel yet; the
+relay refuses them, and that refusal is shown like any other lifecycle error.
+
+**Also add new members to** lists the community's open, unarchived stream
+channels, each with a switch for its starter flag. The welcome channel comes
+first, fixed on, because new members always join it; its own flag is left
+unchanged. A switch takes effect at once: it shows the relay-confirmed state, or
+the relay's refusal or a prompt to try again beside its row. One change runs at a
+time, and Save waits for it. Owners and admins can flag a channel they are not in.
+Turning a channel on adds people who join from then on, not current members; in
+an open community it also adds people who come back and have never been in that
+channel.
## Import from the previous desktop app
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index 19a0dd6e3b..f2c9d5862d 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -83,20 +83,34 @@ fn details_labels(labels: &[Vec]) -> bool {
}
}
}
-/// The welcome message is `""` (restore the default) or 1–1000 scalar values
-/// of plain text, matching the relay's rules.
+/// The welcome message is `""` (restore the default) or 1–4000 scalar values
+/// of Markdown source whose only control characters are `\n` and `\t`,
+/// matching the relay's rules.
fn welcome(text: &str) -> bool {
text.is_empty()
|| (!text.trim().is_empty()
- && text.chars().count() <= 1000
- && text.chars().all(|c| c == '\n' || !c.is_control()))
+ && text.chars().count() <= 4000
+ && text
+ .chars()
+ .all(|c| matches!(c, '\n' | '\t') || !c.is_control()))
+}
+/// The welcome title is `""` (restore the default) or 1–120 scalar values on
+/// one line with no control characters, matching the relay's rules.
+fn welcome_title(text: &str) -> bool {
+ text.is_empty()
+ || (!text.trim().is_empty()
+ && text.chars().count() <= 120
+ && !text.chars().any(char::is_control))
}
fn lifecycle(event: &EventTemplate) -> bool {
- // Owners/admins set the community's welcome channel with its full state.
- if event.kind == 9002 && common(event) && event.tags.len() == 3 {
+ // Owners/admins set the community's welcome channel with its full state:
+ // message and title, or the older form without a title, which keeps it.
+ if event.kind == 9002 && common(event) && matches!(event.tags.len(), 3 | 4) {
return tag(event, 0, "h").is_some_and(uuid)
&& tag(event, 1, "landing") == Some("true")
- && tag(event, 2, "welcome").is_some_and(welcome);
+ && tag(event, 2, "welcome").is_some_and(welcome)
+ && (event.tags.len() == 3
+ || tag(event, 3, "welcome_title").is_some_and(welcome_title));
}
if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012)
|| !common(event)
@@ -508,8 +522,13 @@ mod boundary_tests {
"content": "",
"tags": [["h", channel], ["landing", "true"], ["welcome", ""]]
});
- let longest = "é".repeat(1000);
- for text in ["", "Hi all!\nIntroduce yourself.", longest.as_str()] {
+ let longest = "é".repeat(4000);
+ for text in [
+ "",
+ "Hi all!\nIntroduce yourself.",
+ "# Hi\n\n- one\n\t- two",
+ longest.as_str(),
+ ] {
let mut command = event.clone();
command["tags"][2][1] = serde_json::json!(text);
assert!(validate("channel-lifecycle", &command, None).is_ok());
@@ -517,8 +536,7 @@ mod boundary_tests {
}
for text in [
serde_json::json!(" \n "),
- serde_json::json!("a".repeat(1001)),
- serde_json::json!("tab\there"),
+ serde_json::json!("a".repeat(4001)),
serde_json::json!("line\r\nbreak"),
serde_json::json!("bell\u{7}"),
serde_json::json!("c1\u{85}"),
@@ -529,7 +547,57 @@ mod boundary_tests {
command["tags"][2][1] = text;
assert!(validate("channel-lifecycle", &command, None).is_err());
}
+ // The full-state form adds the title; the three-tag form keeps it.
+ let titled = serde_json::json!({
+ "kind": 9002,
+ "created_at": 1700000010,
+ "content": "",
+ "tags": [
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", ""]
+ ]
+ });
+ let longest_title = "é".repeat(120);
+ for title in ["", "Welcome to Acme", longest_title.as_str()] {
+ let mut command = titled.clone();
+ command["tags"][3][1] = serde_json::json!(title);
+ assert!(validate("channel-lifecycle", &command, None).is_ok());
+ assert!(validate("channel-details", &command, None).is_err());
+ }
+ for title in [
+ serde_json::json!(" "),
+ serde_json::json!("a".repeat(121)),
+ serde_json::json!("two\nlines"),
+ serde_json::json!("tab\there"),
+ serde_json::json!("c1\u{85}"),
+ Value::Null,
+ ] {
+ let mut command = titled.clone();
+ command["tags"][3][1] = title;
+ assert!(validate("channel-lifecycle", &command, None).is_err());
+ }
for tags in [
+ serde_json::json!([
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome_title", ""],
+ ["welcome", ""]
+ ]),
+ serde_json::json!([
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "", "extra"]
+ ]),
+ serde_json::json!([
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", ""],
+ ["welcome_title", ""]
+ ]),
serde_json::json!([["h", channel], ["landing", "false"], ["welcome", ""]]),
serde_json::json!([["h", channel], ["landing", "true"]]),
serde_json::json!([["h", channel], ["welcome", ""]]),
diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx
index 0b6dba7cdc..70d96a32a0 100644
--- a/src/bundled/channels/ChannelBody.tsx
+++ b/src/bundled/channels/ChannelBody.tsx
@@ -9,7 +9,10 @@ import { useChannelWindow } from "../../features/relay/react";
import { useRecoveryOwns } from "../../features/relay/RecoveryNotice";
import { headRecovering } from "../../features/relay/live-recovery";
import { clientMetrics } from "../../features/developer/client-metrics";
-import { CommunityWelcomeCard } from "../../features/communities/WelcomeCard";
+import {
+ CommunityWelcomeCard,
+ WelcomeMessage,
+} from "../../features/communities/WelcomeCard";
import type { CommunityReader } from "../../features/communities/service";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Channels.module.css";
@@ -27,13 +30,16 @@ export const ChannelBody = memo(function ChannelBody({
onContentReady,
landing,
welcomeMessage,
+ welcomeTitle,
communities,
}: {
onContentReady?(channelId: string): void;
/** The community's welcome channel: its timeline starts with a welcome. */
landing?: boolean | undefined;
- /** Custom welcome text; absent shows the default. */
+ /** Custom welcome message (Markdown source); absent shows the default. */
welcomeMessage?: string | undefined;
+ /** Custom welcome title; absent shows the default. */
+ welcomeTitle?: string | undefined;
/** Names the community in the welcome. */
communities?: CommunityReader | undefined;
cached: boolean;
@@ -54,7 +60,11 @@ export const ChannelBody = memo(function ChannelBody({
// The owner's Solo chat is a chat-style timeline that loads history on scroll.
const {
session: queries,
+ scope,
channelId,
+ extensions,
+ onOpenLink,
+ canOpenLink,
ownSoloConversation: solo,
} = useConversation();
// ChannelWorkspace already keys this lifetime by viewer/scope/generation.
@@ -155,8 +165,18 @@ export const ChannelBody = memo(function ChannelBody({
+ title={welcomeTitle}
+ >
+
+
)
}
/>
diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx
index 07e7d7b6d0..0538bae352 100644
--- a/src/bundled/channels/ChannelGeneralAccess.test.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx
@@ -157,7 +157,7 @@ it.each([
);
await select(user, "General access", option);
const starter =
- "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first.";
+ "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn it off in Settings → Community admin → Welcome first.";
const confirm = await screen.findByRole("alertdialog", {
name: "Change general access?",
});
diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx
index b44c64643c..291f51a7cd 100644
--- a/src/bundled/channels/ChannelGeneralAccess.tsx
+++ b/src/bundled/channels/ChannelGeneralAccess.tsx
@@ -357,7 +357,7 @@ function ChannelGeneralAccessEditor({
: "",
// The relay keeps the flag but refuses changing it on a private channel.
choice.visibility === "private" && autoJoin
- ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first."
+ ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn it off in Settings → Community admin → Welcome first."
: "",
!solo && choice.visibility === "public"
? `New people will ${choice.joinRole === "guest" ? "only be able to view" : "be able to participate"}. People already in the ${place} keep their access.`
diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx
index 8d4418e6a8..813a575c95 100644
--- a/src/bundled/channels/ChannelLifecycleDialog.tsx
+++ b/src/bundled/channels/ChannelLifecycleDialog.tsx
@@ -107,7 +107,7 @@ export function ChannelLifecycleDialog({
// The switch is hidden while archived, and Unarchive applies the flag again.
const description =
action === "archive" && autoJoin
- ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.`
+ ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn it off in Settings → Community admin → Welcome first.`
: copy.detail;
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx
index 5dcc3c705d..cd92d688aa 100644
--- a/src/bundled/channels/ChannelSettingsPanel.test.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx
@@ -10,16 +10,8 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createPortal } from "react-dom";
-import { afterEach, assert, expect, it, onTestFinished, vi } from "vitest";
+import { afterEach, expect, it, onTestFinished, vi } from "vitest";
import { ChannelSettingsPanel } from "./ChannelSettingsPanel";
-import type { ChannelSummary } from "../../features/relay/contracts";
-import {
- ChannelLifecycleUnconfirmed,
- type ChannelLifecycleCapability,
-} from "../../features/relay/channel-lifecycle";
-import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
-import type { Role } from "../../features/communities/roster";
-import type { CommunityMembership } from "../moderation/membership";
afterEach(() => {
cleanup();
@@ -300,313 +292,3 @@ it.each(["resolve", "reject"])(
);
},
);
-
-function lifecycle(available = true) {
- return {
- available,
- load: vi.fn(),
- run: vi.fn(async () => {}),
- snapshot: () => ({ status: "ready", hidden: [] }),
- subscribe: () => () => {},
- observeRemoval: () => () => {},
- refreshVisibility: async () => {},
- readLanding: vi.fn(),
- setLanding: vi.fn(),
- } satisfies ChannelLifecycleCapability;
-}
-function membership(role: Role | undefined) {
- const release = vi.fn();
- return {
- snapshot: () => ({
- status: "ready",
- members: [],
- ...(role ? { role } : {}),
- refreshing: false,
- }),
- subscribe: () => () => true,
- ensure: vi.fn(() => release),
- refresh: async () => {},
- dispose: () => {},
- release,
- } satisfies CommunityMembership & { release: unknown };
-}
-const publicStream: ChannelSummary = {
- id: "11111111-1111-4111-8111-111111111111",
- name: "Alpha",
- channelType: "stream",
- visibility: "public",
- members: ["one"],
-};
-function autoJoinPanel(
- channel: ChannelSummary,
- role: Role | undefined,
- capability = lifecycle(),
- roster: CommunityMembership | null = membership(role),
-) {
- return (
- {}}
- >
- Diagnostics
-
- );
-}
-const autoJoinSwitch = () =>
- screen.getByRole("switch", { name: "Starter channel for new members" });
-const noAutoJoinSwitch = () =>
- expect(
- screen.queryByRole("switch", { name: "Starter channel for new members" }),
- ).not.toBeInTheDocument();
-/** Every child of the details list is a `div` of terms then definitions. */
-const expectDefinitionRows = (node: Element) => {
- const list = node.closest("dl");
- assert(list);
- for (const row of list.children)
- expect(
- `${row.tagName}:${[...row.children].map((child) => child.tagName).join(",")}`,
- ).toMatch(/^DIV:DT(,DT)*(,DD)+$/);
-};
-
-// [case, channel, community role, switch shown, roster read held]
-it.each<[string, ChannelSummary, Role | undefined, boolean, boolean]>([
- [
- "a community owner on a public stream channel",
- publicStream,
- "owner",
- true,
- true,
- ],
- [
- "a community admin on a public stream channel",
- publicStream,
- "admin",
- true,
- true,
- ],
- ["an ordinary community member", publicStream, "member", false, true],
- ["an unknown community role", publicStream, undefined, false, true],
- [
- "a private channel",
- { ...publicStream, visibility: "private" },
- "owner",
- false,
- false,
- ],
- [
- "unknown visibility",
- { ...publicStream, visibility: undefined },
- "owner",
- false,
- false,
- ],
- [
- "a forum channel",
- { ...publicStream, channelType: "forum" },
- "owner",
- false,
- false,
- ],
- [
- "an archived channel",
- { ...publicStream, archived: true },
- "owner",
- false,
- false,
- ],
- [
- "a read-only preview",
- { ...publicStream, readOnly: true },
- "owner",
- false,
- false,
- ],
- [
- "a cached channel",
- { ...publicStream, cached: true },
- "owner",
- false,
- false,
- ],
-])(
- "shows the auto-join switch for %s: %s",
- (_, channel, role, shown, ensured) => {
- const roster = membership(role);
- const error = vi.spyOn(console, "error");
- const { unmount } = render(
- autoJoinPanel(channel, role, lifecycle(), roster),
- );
- if (shown) {
- const control = autoJoinSwitch();
- expect(control).toBeVisible();
- expect(control).toHaveAccessibleDescription(
- "New community members are added to this channel automatically.",
- );
- expect(control).not.toBeChecked();
- // The label is the row's term; the switch belongs to its definition.
- expect(control.closest("dd")?.previousElementSibling).toHaveTextContent(
- "Starter channel for new members",
- );
- expectDefinitionRows(control);
- expect(
- screen.getByText("Description").compareDocumentPosition(control),
- ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
- expect(control.compareDocumentPosition(screen.getByText("Members"))).toBe(
- Node.DOCUMENT_POSITION_FOLLOWING,
- );
- } else noAutoJoinSwitch();
- // The row and the channel ID detail are siblings; their keys must differ.
- expect(error).not.toHaveBeenCalledWith(
- expect.stringContaining("same key"),
- expect.anything(),
- );
- // The role read is held only while a channel-eligible row is mounted.
- expect(roster.ensure).toHaveBeenCalledTimes(ensured ? 1 : 0);
- unmount();
- expect(roster.release).toHaveBeenCalledTimes(ensured ? 1 : 0);
- },
-);
-
-it("hides the switch without a membership owner and reflects the signed flag", () => {
- const { rerender } = render(
- autoJoinPanel(publicStream, "owner", lifecycle(), null),
- );
- noAutoJoinSwitch();
- rerender(autoJoinPanel({ ...publicStream, autoJoin: true }, "owner"));
- expect(autoJoinSwitch()).toBeChecked();
-});
-
-it("runs the matching command, stays pending until it settles, then follows the metadata", async () => {
- const user = userEvent.setup();
- const capability = lifecycle();
- let settle!: () => void;
- capability.run.mockReturnValueOnce(
- new Promise((resolve) => {
- settle = resolve;
- }),
- );
- const { rerender } = render(autoJoinPanel(publicStream, "owner", capability));
- // The visible term labels the switch.
- await user.click(screen.getByText("Starter channel for new members"));
- try {
- expect(capability.run).toHaveBeenCalledExactlyOnceWith(
- "auto-join-on",
- publicStream.id,
- expect.any(AbortSignal),
- );
- expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true");
- expect(autoJoinSwitch()).toHaveAttribute("aria-busy", "true");
- // No optimistic state: the switch follows the signed metadata.
- expect(autoJoinSwitch()).not.toBeChecked();
- } finally {
- await act(async () => settle());
- }
- expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled");
- expect(autoJoinSwitch()).not.toHaveAttribute("aria-busy");
- rerender(
- autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability),
- );
- expect(autoJoinSwitch()).toBeChecked();
- await user.click(autoJoinSwitch());
- expect(capability.run).toHaveBeenLastCalledWith(
- "auto-join-off",
- publicStream.id,
- expect.any(AbortSignal),
- );
-});
-
-it.each([
- [
- "the relay's own reason",
- new PublishRejected("rejected", "invalid: actor not authorized"),
- "invalid: actor not authorized",
- ],
- [
- "the default reason",
- new PublishRejected("rejected"),
- "Only community owners and admins can change this.",
- ],
- [
- "a retry for an unconfirmed outcome",
- // As the lifecycle owner throws it when readback never shows the flag.
- new ChannelLifecycleUnconfirmed(
- new Error(
- "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.",
- ),
- ),
- "Saved, but not confirmed yet. Try again to check.",
- ],
- [
- "a retry for a rate limit, not a refusal",
- // As the transport maps a NIP-01 `rate-limited:` OK reply.
- new PublishNotSent("rate-limited: slow down"),
- "Not sent. Try again.",
- ],
-])(
- "reports %s and lets the switch be tried again",
- async (_, failure, copy) => {
- const user = userEvent.setup();
- const capability = lifecycle();
- capability.run.mockRejectedValueOnce(failure);
- render(autoJoinPanel(publicStream, "owner", capability));
- await user.click(autoJoinSwitch());
- const alert = await screen.findByRole("alert");
- expect(alert.textContent).toBe(copy);
- expectDefinitionRows(alert);
- expect(autoJoinSwitch()).toHaveAccessibleDescription(
- `New community members are added to this channel automatically. ${copy}`,
- );
- expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled");
- expect(autoJoinSwitch()).not.toBeChecked();
- await user.click(autoJoinSwitch());
- await waitFor(() =>
- expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
- );
- expect(autoJoinSwitch()).toHaveAccessibleDescription(
- "New community members are added to this channel automatically.",
- );
- expect(capability.run).toHaveBeenCalledTimes(2);
- },
-);
-
-it("drops an unconfirmed prompt once the signed flag shows the change landed", async () => {
- const user = userEvent.setup();
- const capability = lifecycle();
- capability.run.mockRejectedValueOnce(
- new ChannelLifecycleUnconfirmed(new Error("not confirmed")),
- );
- const { rerender } = render(autoJoinPanel(publicStream, "owner", capability));
- await user.click(autoJoinSwitch());
- const alert = await screen.findByRole("alert");
- expect(alert).toHaveTextContent(
- "Saved, but not confirmed yet. Try again to check.",
- );
- expect(autoJoinSwitch().getAttribute("aria-describedby")).toContain(alert.id);
- // A refresh that still lacks the flag keeps the prompt.
- rerender(autoJoinPanel({ ...publicStream }, "owner", capability));
- expect(screen.getByRole("alert")).toBe(alert);
- // A later 39000 shows the flag on; "Try again" would now turn it off.
- rerender(
- autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability),
- );
- expect(autoJoinSwitch()).toBeChecked();
- expect(screen.queryByRole("alert")).not.toBeInTheDocument();
- expect(autoJoinSwitch().getAttribute("aria-describedby")).not.toContain(
- alert.id,
- );
- expect(autoJoinSwitch()).toHaveAccessibleDescription(
- "New community members are added to this channel automatically.",
- );
- // Another admin turning it off later does not bring the stale prompt back.
- rerender(autoJoinPanel(publicStream, "owner", capability));
- expect(screen.queryByRole("alert")).not.toBeInTheDocument();
- expect(capability.run).toHaveBeenCalledOnce();
-});
-
-it("disables the switch when channel actions are unavailable on this connection", () => {
- render(autoJoinPanel(publicStream, "owner", lifecycle(false)));
- expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true");
-});
diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx
index 709200f12a..0139c54bbc 100644
--- a/src/bundled/channels/ChannelSettingsPanel.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.tsx
@@ -4,19 +4,11 @@ import {
useId,
useRef,
useState,
- useSyncExternalStore,
type ReactElement,
type ComponentProps,
type ReactNode,
} from "react";
import type { ChannelSummary } from "../../features/relay/contracts";
-import {
- ChannelLifecycleUnconfirmed,
- type ChannelLifecycleCapability,
-} from "../../features/relay/channel-lifecycle";
-import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
-import { managesCommunity } from "../../features/communities/roster";
-import type { CommunityMembership } from "../moderation/membership";
import type { RelaySession } from "../../features/relay/session";
import { MessageMarkdown } from "../../features/messages/MessageMarkdown";
import { useReferenceDirectory } from "../../features/messages/ReferenceText";
@@ -32,7 +24,6 @@ import { Tabs } from "../../shared/design-system/ui/Tabs";
import { Tooltip } from "../../shared/design-system/ui/Tooltip";
import { Panel } from "../../shared/design-system/ui/Panel";
import { PanelHeader } from "../../shared/design-system/ui/PanelHeader";
-import { Switch } from "../../shared/design-system/ui/Switch";
import styles from "./Channels.module.css";
import type { ChannelDetailsCapability } from "../../features/relay/channel-details";
import {
@@ -48,8 +39,6 @@ export function ChannelSettingsPanel({
children,
setupTools,
details,
- lifecycle,
- membership,
openMembers,
session,
canOpenLink,
@@ -61,9 +50,6 @@ export function ChannelSettingsPanel({
children: ReactNode;
setupTools?: ReactNode;
details?: ChannelDetailsCapability;
- /** With `membership`, offers the community owner/admin auto-join switch. */
- lifecycle?: ChannelLifecycleCapability | undefined;
- membership?: CommunityMembership | undefined;
openMembers?(trigger: HTMLButtonElement): void;
/** Links the description like a message; without these it stays plain text. */
session?: RelaySession | undefined;
@@ -217,18 +203,6 @@ export function ChannelSettingsPanel({
}
/>
)}
- {editable &&
- channel.channelType === "stream" &&
- channel.visibility === "public" &&
- lifecycle &&
- membership && (
-
- )}
{channel.members && (
membership.snapshot().role,
- );
- useEffect(() => membership.ensure(), [membership]);
- const [pending, setPending] = useState(false);
- const [error, setError] = useState<{ enabled: boolean; message: string }>();
- // Once the signed flag shows what a failed toggle wanted, the change is in
- // effect, and "Try again" would flip it back.
- if (error && !!channel.autoJoin === error.enabled) setError(undefined);
- const operation = useRef(undefined);
- useEffect(() => () => operation.current?.abort(), []);
- const control = useId();
- const description = useId();
- const alert = useId();
- if (!managesCommunity(role)) return null;
- const disabled = pending || !lifecycle.available;
- const toggle = async (enabled: boolean) => {
- if (operation.current) return;
- const controller = new AbortController();
- operation.current = controller;
- setPending(true);
- setError(undefined);
- try {
- await lifecycle.run(
- enabled ? "auto-join-on" : "auto-join-off",
- channel.id,
- controller.signal,
- );
- } catch (error) {
- if (!controller.signal.aborted)
- setError({
- enabled,
- message:
- // Setting the same value again is idempotent, so retrying is the check.
- error instanceof ChannelLifecycleUnconfirmed
- ? "Saved, but not confirmed yet. Try again to check."
- : // A rate limit or a write that never left this device, not a refusal.
- error instanceof PublishNotSent
- ? "Not sent. Try again."
- : error instanceof PublishRejected
- ? (error.reason ??
- "Only community owners and admins can change this.")
- : error instanceof Error
- ? error.message
- : String(error),
- });
- } finally {
- operation.current = undefined;
- if (!controller.signal.aborted) setPending(false);
- }
- };
- // A row of the details list: the label is its term, and the description,
- // switch and any error are its definition.
- return (
-
-
- Starter channel for new members
-
-
-
- New community members are added to this channel automatically.
-
- void toggle(enabled)}
- />
- {error && (
-
- {error.message}
-
- )}
-
-
- );
-}
-
/** Only a linked description reads the reference directory, so its snapshots
* re-render this value rather than the whole panel. */
function ChannelDescription({
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index d1732fe7eb..9583c650fb 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -823,18 +823,6 @@ details:not([open]) > summary .sectionChevron {
color: var(--text-subtle);
}
/* The switch trails its description; an error spans the row below both. */
-.settingsSwitch > dd {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: var(--space-1) var(--space-3);
-}
-.settingsSwitch > dd > [role="alert"] {
- grid-column: 1 / -1;
-}
-.settingsSwitch[data-disabled] > dt {
- color: var(--text-unavailable);
-}
.settingsId {
overflow-wrap: anywhere;
}
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 7427a81340..4f671c726d 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -45,7 +45,6 @@ import { setThreadPlacementPreference } from "../../features/messages/thread-pre
import { SOLO_LABEL } from "../../features/solo/labels";
import { composerDOMFixture } from "../../features/messages/composer-testing";
import { ChannelsPage } from "./ChannelsPage";
-import type { CommunityMembership } from "../moderation/membership";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { BrowserHostView } from "../../features/browser/BrowserHostView";
import { BrowserSessions } from "../../features/browser/sessions";
@@ -310,13 +309,6 @@ function workspace(
events: RelayEvent[],
viewer = keypair(),
authority = keypair(),
- {
- membership,
- lifecycle,
- }: {
- membership?: CommunityMembership;
- lifecycle?: (event: RelayEvent) => Promise;
- } = {},
) {
const owner = createRelaySession(
{
@@ -331,12 +323,6 @@ function workspace(
sign: async (template) => signed(viewer, template),
publish: async () => {},
},
- ...(lifecycle && {
- channelLifecycle: {
- sign: async (template) => signed(viewer, template),
- publish: lifecycle,
- },
- }),
},
{ outboxStorage: { load: () => [], save: () => {} } },
);
@@ -374,7 +360,6 @@ function workspace(
panels={panels}
navigator={navigator}
navigation={navigation}
- membership={membership}
/>
);
@@ -434,15 +419,20 @@ it("opens the community's welcome channel before its auto-join channel and start
metadata(authority, "beta", "Beta", undefined, [
["t", "stream"],
["landing"],
- ["welcome", "Say hi\nand introduce yourself."],
+ ["welcome", "Say **hi**\nand introduce yourself."],
+ ["welcome_title", "Hello, newcomer"],
]),
],
viewer,
authority,
);
await screen.findByRole("textbox", { name: "Message #Beta" });
- const welcome = await screen.findByRole("region", { name: "Welcome" });
+ const welcome = await screen.findByRole("region", {
+ name: "Hello, newcomer",
+ });
+ // The message is Markdown, rendered as a message body is.
expect(welcome).toHaveTextContent("Say hi and introduce yourself.");
+ expect(welcome.querySelector("strong")).toHaveTextContent("hi");
// The welcome is not a message and replaces the empty-conversation notice.
expect(welcome.closest("[data-message-id]")).toBeNull();
expect(screen.queryByText("No messages yet.")).toBeNull();
@@ -587,54 +577,6 @@ it("opens the welcome channel as soon as it arrives while the opening state wait
).toBeNull();
});
-it("hands the community roster to Channel settings and follows the republished flag", async () => {
- const viewer = keypair(),
- authority = keypair();
- const id = "11111111-1111-4111-8111-111111111111";
- const open = (at: number, extra: string[][] = []) =>
- metadata(authority, id, "Alpha", at, [
- ["t", "stream"],
- ["public"],
- ...extra,
- ]);
- const events = [roster(authority, id, [viewer.pubkey]), open(1_700_000_000)];
- const ensure = vi.fn(() => () => {});
- workspace(events, viewer, authority, {
- membership: {
- snapshot: () => ({
- status: "ready",
- members: [],
- role: "owner",
- refreshing: false,
- }),
- subscribe: () => () => true,
- ensure,
- refresh: async () => {},
- dispose: () => {},
- },
- // The relay applies the accepted 9002 and republishes its metadata.
- lifecycle: async (event) => {
- if (event.kind === 9002)
- events.push(open(1_700_000_100, [["auto_join"]]));
- },
- });
- await screen.findByRole("textbox", { name: "Message #Alpha" });
- const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: "Channel actions" }));
- await user.click(
- await screen.findByRole("menuitem", { name: "View channel details" }),
- );
- const control = await screen.findByRole("switch", {
- name: "Starter channel for new members",
- });
- expect(ensure).toHaveBeenCalledOnce();
- expect(control).not.toBeChecked();
- await user.click(control);
- await vi.waitFor(() => expect(control).toBeChecked());
- expect(control).not.toHaveAttribute("aria-busy");
- expect(control.closest("dd")?.querySelector("[role=alert]")).toBeNull();
-});
-
it("selects a default channel the relay adds after the page opened", async () => {
const events: RelayEvent[] = [];
const { owner, viewer, authority } = workspace(events);
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 32dd31ef22..0d6ad51d84 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -158,7 +158,7 @@ export function ChannelsPage({
/** For adding a whole team of the owner's agents from the members dialog. */
agents?: Agents | undefined;
communities?: CommunityReader | undefined;
- /** The viewer's community role, for the auto-join switch in Channel settings. */
+ /** The viewer's community role, for creating #general in an empty community. */
membership?: CommunityMembership | undefined;
relay: RelayData;
navigation?: PageNavigation | undefined;
@@ -1994,8 +1994,6 @@ function ChannelWorkspace({
}
channel={current}
details={queries.channelDetails}
- lifecycle={queries.channelLifecycle}
- membership={membership}
session={queries}
canOpenLink={canOpenLink}
onOpenLink={openLink}
@@ -2102,6 +2100,7 @@ function ChannelWorkspace({
}
landing={current.landing && current.channelType === "stream"}
welcomeMessage={current.welcome}
+ welcomeTitle={current.welcomeTitle}
communities={communities}
/>
) : noChannels ? (
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 1164794783..7fce68b117 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -20,5 +20,10 @@ for a possible return, but is hidden in the shipped UI.
On relays that support it, **Settings → Community admin → Welcome** lets an
owner or admin choose the public channel new members join and open first, and
-the message shown at the start of that channel. Saving moves the welcome to the
-chosen channel; people already in the community keep their channels.
+the title and Markdown message shown at the start of that channel. Saving moves
+the welcome to the chosen channel; people already in the community keep their
+channels.
+
+Below that, **Also add new members to** turns other open channels into starter
+channels, which new members join too. Each switch applies at once, to people who
+join afterwards. The list also appears on relays without a welcome channel.
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index bce47fbd0b..9e08aa6b25 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -7,6 +7,7 @@ import {
render,
screen,
waitFor,
+ within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
@@ -20,6 +21,7 @@ import {
type ChannelLanding,
type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
+import type { ChannelSummary } from "../../features/relay/contracts";
import { PublishRejected } from "../../features/relay/outbox";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
@@ -41,7 +43,17 @@ const lobby = "11111111-1111-4111-8111-111111111111";
const square = "22222222-2222-4222-8222-222222222222";
const rows = [
{ id: lobby, name: "Lobby", channelType: "stream" as const },
- { id: square, name: "Town square", channelType: "stream" as const },
+ {
+ id: square,
+ name: "Town square",
+ channelType: "stream" as const,
+ autoJoin: true as const,
+ },
+ {
+ id: "55555555-5555-4555-8555-555555555555",
+ name: "Random",
+ channelType: "stream" as const,
+ },
{
id: "33333333-3333-4333-8333-333333333333",
name: "Old news",
@@ -58,17 +70,26 @@ const rows = [
function setup({
info = { policy: null, landingChannels: true, landingChannel: lobby },
landing = { landing: true, welcome: "Hi there", name: "Lobby" },
+ joined = [],
}: {
info?: CommunityInfo;
landing?: ChannelLanding | undefined;
+ /** The viewer's own channels, beside the directory. */
+ joined?: ChannelSummary[];
} = {}) {
const readLanding = vi.fn(async () => landing);
const setLanding = vi.fn(
async () => {},
);
+ const run = vi.fn(async () => {});
+ const list = { status: "ready", channels: joined };
const session = {
- channelLifecycle: { available: true, readLanding, setLanding },
+ viewer: owner,
+ media: () => undefined,
+ channelLifecycle: { available: true, readLanding, setLanding, run },
channels: {
+ list: () => list,
+ ensureList: () => () => {},
subscribeList: () => () => {},
subscribeDirectory: () => () => {},
openDirectory: () => rows,
@@ -108,15 +129,32 @@ function setup({
/>,
{ wrapper: ToastProvider },
);
- return { readLanding, setLanding, reconnect, view, user: userEvent.setup() };
+ return {
+ readLanding,
+ setLanding,
+ run,
+ reconnect,
+ view,
+ user: userEvent.setup(),
+ };
}
const channel = () => screen.getByRole("combobox", { name: "Welcome channel" });
+const titleField = () => screen.getByRole("textbox", { name: "Title" });
const messageField = () => screen.getByRole("textbox", { name: "Message" });
+const starter = (name: string) => screen.getByRole("switch", { name });
+const welcome = (message: string, title = "") => ({ message, title });
-it("opens on the designated channel and its message, read fresh", async () => {
+it("opens on the designated channel, its title and its message, read fresh", async () => {
const { readLanding } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ // The chosen channel shows its icon, as in the list.
+ expect(
+ channel()
+ .closest(".buzz-input-group")
+ ?.querySelector(".buzz-input-leading svg"),
+ ).not.toBeNull();
+ expect(titleField()).toHaveValue("Welcome to Primary");
expect(messageField()).toHaveValue("Hi there");
expect(communityRequest).toHaveBeenCalledWith(
"https://primary.example",
@@ -132,20 +170,42 @@ it("opens on the designated channel and its message, read fresh", async () => {
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
+it("opens on a custom title and renders the message as Markdown in the preview", async () => {
+ setup({
+ landing: {
+ landing: true,
+ welcome: "Say **hi**",
+ welcomeTitle: "Hello, newcomer",
+ name: "Lobby",
+ },
+ });
+ await waitFor(() => expect(titleField()).toHaveValue("Hello, newcomer"));
+ const preview = screen.getByRole("region", { name: "Hello, newcomer" });
+ expect(preview.querySelector("strong")).toHaveTextContent("hi");
+ expect(
+ screen.getByText(/Markdown works\. To link a channel, use Copy link/),
+ ).toBeVisible();
+});
+
it("moves the welcome to another public channel with its full state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
await user.click(screen.getByRole("button", { name: "Browse channels" }));
- // Only active public stream channels are offered.
- expect(
- (await screen.findAllByRole("option")).map((option) => option.textContent),
- ).toEqual(["Lobby", "Town square"]);
+ // Only active public stream channels are offered, each with its icon.
+ const options = await screen.findAllByRole("option");
+ expect(options.map((option) => option.textContent)).toEqual([
+ "Lobby",
+ "Random",
+ "Town square",
+ ]);
+ for (const option of options)
+ expect(option.querySelector("svg")).not.toBeNull();
await user.click(screen.getByRole("option", { name: "Town square" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
square,
- "Hi there",
+ welcome("Hi there"),
expect.any(AbortSignal),
expect.any(Function),
),
@@ -154,7 +214,7 @@ it("moves the welcome to another public channel with its full state", async () =
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
-it("requires a channel when none is designated and sends the default as empty", async () => {
+it("requires a channel when none is designated and sends the defaults as empty", async () => {
const { readLanding, setLanding, user } = setup({
info: { policy: null, landingChannels: true },
});
@@ -164,57 +224,87 @@ it("requires a channel when none is designated and sends the default as empty",
fireEvent.change(messageField(), {
target: { value: `${DEFAULT_WELCOME_MESSAGE}\n` },
});
+ fireEvent.change(titleField(), { target: { value: " Welcome to Primary " } });
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.type(channel(), "town");
await user.click(await screen.findByRole("option", { name: "Town square" }));
await user.click(screen.getByRole("button", { name: "Save" }));
- // The normalized default is never stored as custom text.
+ // The normalized defaults are never stored as custom text.
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
square,
- "",
+ welcome(""),
expect.any(AbortSignal),
expect.any(Function),
),
);
});
-it("normalizes line endings and trims the message before sending", async () => {
+it("normalizes line endings and trims the title and message before sending", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
fireEvent.change(messageField(), { target: { value: " Hi\r\nall " } });
+ fireEvent.change(titleField(), { target: { value: " Hello, newcomer " } });
expect(
- screen.getByRole("region", { name: "Welcome to Primary" }),
+ screen.getByRole("region", { name: "Hello, newcomer" }),
).toHaveTextContent("Hi all");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
lobby,
- "Hi\nall",
+ welcome("Hi\nall", "Hello, newcomer"),
expect.any(AbortSignal),
expect.any(Function),
),
);
});
-it("blocks messages the relay would refuse and restores the default in the draft", async () => {
+it("blocks messages the relay would refuse and restores the defaults in the draft", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
fireEvent.change(messageField(), { target: { value: " \n " } });
expect(
- screen.getByText("Enter a welcome message, or restore the default."),
+ screen.getByText("Enter a welcome message, or restore the defaults."),
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- fireEvent.change(messageField(), { target: { value: "a".repeat(1001) } });
- expect(screen.getByText("Shorten the message to 1,000 characters.")).toBeVisible();
- // Unicode scalar values, not UTF-16 units, as the relay counts.
- fireEvent.change(messageField(), { target: { value: "😀".repeat(1000) } });
- expect(screen.getByText("1,000 of 1,000 characters")).toBeVisible();
+ fireEvent.change(messageField(), { target: { value: "a".repeat(4001) } });
+ expect(screen.getByText("Shorten the message to 4,000 characters.")).toBeVisible();
+ fireEvent.change(messageField(), { target: { value: "a\u0007b" } });
+ expect(
+ screen.getByText("Remove control characters. Line breaks and tabs are fine."),
+ ).toBeVisible();
+ // Unicode scalar values, not UTF-16 units, as the relay counts; tabs pass.
+ fireEvent.change(messageField(), {
+ target: { value: `${"😀".repeat(1999)}\t${"😀".repeat(2000)}` },
+ });
+ expect(screen.getByText("4,000 of 4,000 characters")).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- await user.click(
- screen.getByRole("button", { name: "Restore default message" }),
- );
+ fireEvent.change(titleField(), { target: { value: "Hello" } });
+ await user.click(screen.getByRole("button", { name: "Restore defaults" }));
expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE);
+ expect(titleField()).toHaveValue("Welcome to Primary");
+ expect(
+ screen.queryByRole("button", { name: "Restore defaults" }),
+ ).toBeNull();
+ expect(setLanding).not.toHaveBeenCalled();
+});
+
+it("blocks titles the relay would refuse", async () => {
+ const { setLanding } = setup();
+ await waitFor(() => expect(titleField()).toHaveValue("Welcome to Primary"));
+ fireEvent.change(titleField(), { target: { value: " " } });
+ expect(
+ screen.getByText("Enter a title, or restore the defaults."),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(titleField(), { target: { value: "a".repeat(121) } });
+ expect(screen.getByText("Shorten the title to 120 characters.")).toBeVisible();
+ fireEvent.change(titleField(), { target: { value: "a\tb" } });
+ expect(screen.getByText("Use one line of text for the title.")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(titleField(), { target: { value: "😀".repeat(120) } });
+ expect(screen.getByText("120 of 120 characters")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
expect(setLanding).not.toHaveBeenCalled();
});
@@ -365,3 +455,153 @@ it("offers a retry when the current welcome cannot be read", async () => {
);
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
});
+
+it("lists the other open channels as starters, the welcome channel fixed first", async () => {
+ setup({
+ joined: [
+ // Joined channels the directory leaves out are listed; others are not.
+ {
+ id: "66666666-6666-4666-8666-666666666666",
+ name: "Unlisted",
+ channelType: "stream",
+ visibility: "public",
+ autoJoin: true,
+ },
+ {
+ id: "77777777-7777-4777-8777-777777777777",
+ name: "Staff",
+ channelType: "stream",
+ visibility: "private",
+ private: true,
+ },
+ ],
+ });
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ const group = screen.getByRole("region", { name: "Also add new members to" });
+ expect(
+ within(group)
+ .getAllByRole("switch")
+ .map((control) => control.getAttribute("aria-label")),
+ ).toEqual(["Lobby", "Random", "Town square", "Unlisted"]);
+ const fixed = starter("Lobby");
+ expect(fixed).toBeChecked();
+ expect(fixed).toHaveAttribute("aria-disabled", "true");
+ expect(fixed).toHaveAccessibleDescription("Welcome channel · always joined");
+ expect(starter("Random")).not.toBeChecked();
+ expect(starter("Town square")).toBeChecked();
+ expect(starter("Unlisted")).toBeChecked();
+ expect(starter("Random")).not.toHaveAttribute("aria-disabled");
+ expect(within(group).queryByRole("switch", { name: "Staff" })).toBeNull();
+ expect(group).toHaveTextContent(
+ "Turning a channel on adds people who join from now on",
+ );
+});
+
+it("sets a starter's flag through the channel lifecycle and holds other changes until it settles", async () => {
+ const { run, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ let settle!: () => void;
+ run.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ await user.click(starter("Random"));
+ try {
+ expect(run).toHaveBeenCalledWith(
+ "auto-join-on",
+ "55555555-5555-4555-8555-555555555555",
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ // One channel command at a time, and the welcome waits for it.
+ expect(starter("Random")).not.toBeChecked();
+ expect(starter("Random")).toHaveAttribute("aria-busy", "true");
+ expect(starter("Town square")).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ } finally {
+ await act(async () => settle());
+ }
+ // The confirmed flag shows; the directory row is not yet republished.
+ expect(starter("Random")).toBeChecked();
+ expect(starter("Town square")).not.toHaveAttribute("aria-disabled");
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ await user.click(starter("Town square"));
+ expect(run).toHaveBeenLastCalledWith(
+ "auto-join-off",
+ square,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ await waitFor(() => expect(starter("Town square")).not.toBeChecked());
+});
+
+it("explains a starter change that failed beside its row", async () => {
+ const { run, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ run.mockRejectedValueOnce(new PublishRejected("rejected"));
+ await user.click(starter("Random"));
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Only community owners and admins can change this.",
+ );
+ // The message follows its row, before the next one.
+ expect(
+ starter("Random").compareDocumentPosition(alert) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ alert.compareDocumentPosition(starter("Town square")) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(starter("Random")).not.toBeChecked();
+ // Repeating the same value is how an unconfirmed change is checked.
+ run.mockRejectedValueOnce(
+ new ChannelLifecycleUnconfirmed(new Error("timeout")),
+ );
+ await user.click(starter("Random"));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Saved, but not confirmed yet. Try again to check.",
+ );
+ await user.click(starter("Random"));
+ await waitFor(() => expect(starter("Random")).toBeChecked());
+ expect(screen.queryByRole("alert")).toBeNull();
+});
+
+it("holds the starter switches while the welcome saves", async () => {
+ const { setLanding, user } = setup();
+ let settle!: () => void;
+ setLanding.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ try {
+ expect(starter("Random")).toHaveAttribute("aria-disabled", "true");
+ } finally {
+ await act(async () => settle());
+ }
+ expect(starter("Random")).not.toHaveAttribute("aria-disabled");
+});
+
+it("still lists starter channels when the relay has no welcome channel", async () => {
+ const { readLanding } = setup({
+ info: { policy: null, landingChannels: false },
+ });
+ expect(
+ await screen.findByText(
+ "This community’s relay doesn’t support a welcome channel.",
+ ),
+ ).toBeVisible();
+ await waitFor(() => expect(starter("Random")).toBeVisible());
+ expect(starter("Town square")).toBeChecked();
+ expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
+ expect(screen.queryByText("Welcome channel · always joined")).toBeNull();
+ expect(readLanding).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 5a913a5f81..b35af5e108 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -1,31 +1,47 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import {
communityRequest,
type CommunityInfo,
} from "../../features/communities/api";
import {
DEFAULT_WELCOME_MESSAGE,
+ defaultWelcomeTitle,
WelcomeCard,
+ WelcomeMessage,
} from "../../features/communities/WelcomeCard";
+import { channelIcon } from "../../features/channels/channel-icon";
import { useChannelDirectory } from "../../features/channels/useChannelDirectory";
-import { ChannelLifecycleUnconfirmed } from "../../features/relay/channel-lifecycle";
+import {
+ ChannelLifecycleUnconfirmed,
+ type ChannelLifecycleCapability,
+} from "../../features/relay/channel-lifecycle";
+import type { WelcomeCommand } from "../../features/relay/channel-lifecycle-protocol";
+import type { ChannelSummary } from "../../features/relay/contracts";
import { communityFromScope } from "../../features/relay/gifs";
import {
normalizeWelcome,
WELCOME_MAX_LENGTH,
+ WELCOME_TITLE_MAX_LENGTH,
welcomeTextProblem,
+ welcomeTitleProblem,
} from "../../features/relay/landing";
import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
-import { useRelayConnection } from "../../features/relay/react";
+import {
+ useChannelRoster,
+ useRelayConnection,
+} from "../../features/relay/react";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import { matchName } from "../../features/search/match";
import { Button } from "../../shared/design-system/ui/Button";
+import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
import { Combobox } from "../../shared/design-system/ui/Combobox";
import { Field } from "../../shared/design-system/ui/Field";
import { Header, InlineHeader } from "../../shared/design-system/ui/Header";
+import { Input } from "../../shared/design-system/ui/Input";
import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
+import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow";
import { Textarea } from "../../shared/design-system/ui/Textarea";
import { ToastNotice } from "../../shared/design-system/ui/Toast";
import styles from "./WelcomeSettings.module.css";
@@ -33,12 +49,22 @@ import styles from "./WelcomeSettings.module.css";
const message = (reason: unknown) =>
reason instanceof Error ? reason.message : String(reason);
const MAX = WELCOME_MAX_LENGTH.toLocaleString("en-US");
+const TITLE_MAX = WELCOME_TITLE_MAX_LENGTH.toLocaleString("en-US");
+// Every listed channel is an open stream, so each shows the public icon.
+const ChannelIcon = channelIcon({ channelType: "stream" });
-/** The default is never stored: a draft matching it restores the default. */
-const commandFor = (text: string) => {
- const normalized = normalizeWelcome(text);
- return normalized === DEFAULT_WELCOME_MESSAGE ? "" : normalized;
-};
+/** Defaults are never stored: a draft part matching one restores it. */
+function commandFor(
+ draft: Readonly<{ text: string; title: string }>,
+ defaultTitle: string,
+): WelcomeCommand {
+ const text = normalizeWelcome(draft.text);
+ const title = draft.title.trim();
+ return {
+ message: text === DEFAULT_WELCOME_MESSAGE ? "" : text,
+ title: title === defaultTitle ? "" : title,
+ };
+}
/** What a definite failure means for the draft, which is always kept. */
function saveError(error: unknown) {
@@ -53,12 +79,18 @@ function saveError(error: unknown) {
}
const problems = {
- empty: "Enter a welcome message, or restore the default.",
+ empty: "Enter a welcome message, or restore the defaults.",
long: `Shorten the message to ${MAX} characters.`,
- control: "Use plain text. Line breaks are the only formatting.",
+ control: "Remove control characters. Line breaks and tabs are fine.",
+};
+const titleProblems = {
+ empty: "Enter a title, or restore the defaults.",
+ long: `Shorten the title to ${TITLE_MAX} characters.`,
+ control: "Use one line of text for the title.",
};
-/** Community settings: where new members start and what they read there. */
+/** Community settings: where new members start, what they read there, and
+ * which other channels they join. */
export function WelcomeSettings({
relay,
communityName,
@@ -78,12 +110,13 @@ export function WelcomeSettings({
- {community ? (
- ;
+/** The relay's current designation; `welcome` and `title` are `""` for the
+ * defaults. */
+type Published = Readonly<{
+ channelId?: string;
+ name?: string;
+ welcome: string;
+ title: string;
+}>;
type Load =
| { status: "loading" }
| { status: "ready"; published: Published }
| { status: "unsupported" }
| { status: "error"; error: string };
-type Option = Readonly<{ id: string; name: string }>;
+type Option = Readonly<{ id: string; name: string; autoJoin: boolean }>;
+type Draft = Readonly<{ channelId?: string; text: string; title: string }>;
+
+/** Open, active stream channels: the directory's, joined ones it may leave
+ * out (such as unlisted ones), and the current welcome channel. The joined
+ * list is live, so its flags win. */
+function eligibleChannels(
+ directory: ReturnType["directory"],
+ joined: readonly ChannelSummary[],
+ current: Published | undefined,
+) {
+ const rows = new Map();
+ for (const channel of directory)
+ if (channel.channelType === "stream" && !channel.archived)
+ rows.set(channel.id, {
+ id: channel.id,
+ name: channel.name,
+ autoJoin: !!channel.autoJoin,
+ });
+ for (const channel of joined)
+ if (
+ channel.channelType === "stream" &&
+ channel.visibility === "public" &&
+ !channel.archived &&
+ !channel.huddle
+ )
+ rows.set(channel.id, {
+ id: channel.id,
+ name: channel.name,
+ autoJoin: !!channel.autoJoin,
+ });
+ const id = current?.channelId;
+ if (id && !rows.has(id))
+ rows.set(id, { id, name: current.name ?? id.slice(0, 8), autoJoin: false });
+ return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
-function WelcomeForm({
+function WelcomeEditor({
session,
+ scope,
community,
communityName,
active,
}: {
session: RelaySession;
+ scope: string;
community: string;
communityName?: string | undefined;
active(): boolean;
}) {
const lifecycle = session.channelLifecycle;
+ const defaultTitle = defaultWelcomeTitle(communityName);
const [attempt, setAttempt] = useState(0);
const [load, setLoad] = useState({ status: "loading" });
- const [draft, setDraft] = useState<{ channelId?: string; text: string }>();
+ const [draft, setDraft] = useState();
const [typed, setTyped] = useState("");
const [saving, setSaving] = useState(false);
+ // The starter channel whose flag is being changed. The session runs one
+ // channel command at a time, so Save waits for it and it waits for Save.
+ const [toggling, setToggling] = useState();
const [error, setError] = useState("");
// An unconfirmed publication: only a fresh read, never a repeat, settles it.
const [unconfirmed, setUnconfirmed] = useState(false);
@@ -129,6 +209,8 @@ function WelcomeForm({
// The check compares the relay's answer with the draft as it stands then.
const draftRef = useRef(draft);
draftRef.current = draft;
+ const defaultTitleRef = useRef(defaultTitle);
+ defaultTitleRef.current = defaultTitle;
const checkButton = useRef(null);
const messageInput = useRef(null);
const mounted = useRef(true);
@@ -140,7 +222,7 @@ function WelcomeForm({
saveController.current?.abort();
};
}, []);
- // Each open reads the live designation, then its signed message.
+ // Each open reads the live designation, then its signed welcome.
// biome-ignore lint/correctness/useExhaustiveDependencies: An explicit retry reruns this finite read.
useEffect(() => {
const reading = new AbortController();
@@ -171,13 +253,16 @@ function WelcomeForm({
...(id && state ? { channelId: id } : {}),
...(state?.name ? { name: state.name } : {}),
welcome: (state?.landing && state.welcome) || "",
+ title: (state?.landing && state.welcomeTitle) || "",
};
// A draft the relay now shows has landed; there is nothing to save.
const current = draftRef.current;
+ const wanted = current && commandFor(current, defaultTitleRef.current);
const landed =
- !!current &&
- current.channelId === published.channelId &&
- commandFor(current.text) === published.welcome;
+ !!wanted &&
+ current?.channelId === published.channelId &&
+ wanted.message === published.welcome &&
+ wanted.title === published.title;
setLoad({ status: "ready", published });
if (landed) {
setDraft(undefined);
@@ -197,28 +282,26 @@ function WelcomeForm({
return () => reading.abort();
}, [community, lifecycle, attempt]);
const published = load.status === "ready" ? load.published : undefined;
- const directory = useChannelDirectory(session.channels, !!published);
- const options = useMemo(() => {
- const rows: Option[] = directory.directory
- .filter((channel) => channel.channelType === "stream" && !channel.archived)
- .map(({ id, name }) => ({ id, name }));
- // The current welcome channel may be unlisted from the directory.
- const current = published?.channelId;
- if (current && !rows.some((row) => row.id === current))
- rows.unshift({
- id: current,
- name: published.name ?? current.slice(0, 8),
- });
- return rows;
- }, [directory.directory, published]);
+ const listing = load.status === "ready" || load.status === "unsupported";
+ const directory = useChannelDirectory(session.channels, listing);
+ const roster = useChannelRoster(session.channels);
+ const options = useMemo(
+ () => eligibleChannels(directory.directory, roster.channels, published),
+ [directory.directory, roster.channels, published],
+ );
const channelId = draft ? draft.channelId : published?.channelId;
const text = draft?.text ?? (published?.welcome || DEFAULT_WELCOME_MESSAGE);
+ const title = draft?.title ?? (published?.title || defaultTitle);
const normalized = normalizeWelcome(text);
- const command = commandFor(text);
+ const trimmedTitle = title.trim();
+ const command = commandFor({ text, title }, defaultTitle);
const problem = welcomeTextProblem(normalized);
+ const titleProblem = welcomeTitleProblem(trimmedTitle);
const dirty =
!!published &&
- (channelId !== published.channelId || command !== published.welcome);
+ (channelId !== published.channelId ||
+ command.message !== published.welcome ||
+ command.title !== published.title);
const selected = options.find((option) => option.id === channelId) ?? null;
// The input shows the chosen name; browsing from there lists every channel.
const needle = typed === selected?.name ? "" : typed.trim().toLowerCase();
@@ -235,16 +318,36 @@ function WelcomeForm({
: options,
[options, needle],
);
- const edit = (patch: { channelId?: string; text?: string }) => {
+ const edit = (patch: Partial) => {
setDraft({
...(channelId ? { channelId } : {}),
text,
+ title,
...patch,
});
setSaved(false);
setError("");
};
const disabled = saving || !published || !lifecycle.available;
+ const starters = (
+ option.id === published.channelId)
+ : undefined
+ }
+ lifecycle={lifecycle}
+ active={() => mounted.current && active()}
+ saving={saving}
+ pending={toggling}
+ onPendingChange={setToggling}
+ loading={directory.loading}
+ failed={!!directory.error}
+ // The welcome form offers this retry when it is shown.
+ retry={load.status === "unsupported" ? directory.retry : undefined}
+ />
+ );
return (
-
-
- {
- event.preventDefault();
- if (disabled || !dirty || !channelId || problem) return;
- const target = channelId;
- const sent = command;
- const name = options.find((option) => option.id === target)?.name;
- const owner = new AbortController();
- saveController.current = owner;
- setSaving(true);
- setSaved(false);
- setError("");
- void (async () => {
- try {
- await lifecycle.setLanding(
- target,
- sent,
- owner.signal,
- () => mounted.current && active(),
- );
- if (!mounted.current) return;
- setLoad({
- status: "ready",
- published: {
- channelId: target,
- ...(name ? { name } : {}),
- welcome: sent,
- },
- });
- setDraft(undefined);
- setSaved(true);
- } catch (reason) {
- if (!mounted.current || owner.signal.aborted) return;
- if (reason instanceof ChannelLifecycleUnconfirmed)
- setUnconfirmed(true);
- else setError(saveError(reason));
- } finally {
- if (mounted.current) setSaving(false);
- }
- })();
- }}
- >
-
- items={options}
- filteredItems={matches}
- value={selected}
- onValueChange={(option) => {
- if (option) edit({ channelId: option.id });
+ {load.status === "unsupported" ? (
+ starters
+ ) : (
+
+
+ {
+ event.preventDefault();
+ if (
+ disabled ||
+ !dirty ||
+ !channelId ||
+ problem ||
+ titleProblem ||
+ toggling
+ )
+ return;
+ const target = channelId;
+ const sent = command;
+ const name = options.find(
+ (option) => option.id === target,
+ )?.name;
+ const owner = new AbortController();
+ saveController.current = owner;
+ setSaving(true);
+ setSaved(false);
+ setError("");
+ void (async () => {
+ try {
+ await lifecycle.setLanding(
+ target,
+ sent,
+ owner.signal,
+ () => mounted.current && active(),
+ );
+ if (!mounted.current) return;
+ setLoad({
+ status: "ready",
+ published: {
+ channelId: target,
+ ...(name ? { name } : {}),
+ welcome: sent.message,
+ title: sent.title,
+ },
+ });
+ setDraft(undefined);
+ setSaved(true);
+ } catch (reason) {
+ if (!mounted.current || owner.signal.aborted) return;
+ if (reason instanceof ChannelLifecycleUnconfirmed)
+ setUnconfirmed(true);
+ else setError(saveError(reason));
+ } finally {
+ if (mounted.current) setSaving(false);
+ }
+ })();
}}
- onInputValueChange={setTyped}
- itemToStringLabel={(option) => option.name}
- itemToStringValue={(option) => option.id}
- isItemEqualToValue={(a, b) => a.id === b.id}
- disabled={disabled}
- autoHighlight
>
-
-
+ items={options}
+ filteredItems={matches}
+ value={selected}
+ onValueChange={(option) => {
+ if (option) edit({ channelId: option.id });
+ }}
+ onInputValueChange={setTyped}
+ itemToStringLabel={(option) => option.name}
+ itemToStringValue={(option) => option.id}
+ isItemEqualToValue={(a, b) => a.id === b.id}
+ disabled={disabled}
+ autoHighlight
+ >
+
+ ) : undefined
+ }
+ loading={directory.loading}
+ description="New members join this channel and open it first. People already in the community keep their channels."
+ error={
+ directory.error
+ ? "Public channels couldn’t be loaded."
+ : undefined
+ }
+ />
+
+
+ {(option: Option) => (
+
+ }
+ label={option.name}
+ />
+
+ )}
+
+
+
+ {directory.error && (
+
+
+ Retry loading channels
+
+
+ )}
+
+ edit({ title: event.target.value })}
+ />
+
+
+ Markdown works. To link a channel, use Copy link in its
+ menu and paste the link here.{" "}
+
+ {[...normalized].length.toLocaleString("en-US")} of {MAX}{" "}
+ characters
+
+ >
}
+ error={problem && problems[problem]}
>
-
- {(option: Option) => (
-
- {option.name}
-
- )}
-
-
-
- {directory.error && (
-
-
- Retry loading channels
-
-
- )}
-
- edit({ text: event.target.value })}
- />
-
- {normalized !== DEFAULT_WELCOME_MESSAGE && (
-
- edit({ text: DEFAULT_WELCOME_MESSAGE })}
- >
- Restore default message
-
-
- )}
- {error && (
-
- {error}
-
- )}
- {unconfirmed && (
-
-
- Not confirmed yet. The relay hasn’t shown this change; check
- before saving again.
-
+ value={text}
+ onChange={(event) => edit({ text: event.target.value })}
+ />
+
+ {(normalized !== DEFAULT_WELCOME_MESSAGE ||
+ trimmedTitle !== defaultTitle) && (
+ edit({ text: DEFAULT_WELCOME_MESSAGE, title: defaultTitle })
+ }
+ >
+ Restore defaults
+
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {unconfirmed && (
+
+
+ Not confirmed yet. The relay hasn’t shown this change; check
+ before saving again.
+
+
+ {
+ setChecking(true);
+ setAttempt((n) => n + 1);
+ }}
+ >
+ Check again
+
+
+
+ )}
+ {draft && (
+
+ {
- setChecking(true);
- setAttempt((n) => n + 1);
+ setDraft(undefined);
+ setError("");
}}
>
- Check again
+ Cancel
+
+
+ Save
-
- )}
- {draft && (
-
- {
- setDraft(undefined);
- setError("");
- }}
- >
- Cancel
-
-
- Save
-
-
- )}
- {saved && (
- setSaved(false)}
+ )}
+ {saved && (
+ setSaved(false)}
+ />
+ )}
+
+
+
+
+
+ false}
+ interactive={false}
/>
- )}
-
-
-
-
-
+
+
+ {starters}
-
+ )}
);
}
+
+type RowError = Readonly<{ enabled: boolean; message: string }>;
+
+/**
+ * Immediate preferences: each switch is the relay's `auto_join` flag for one
+ * channel, confirmed by a signed readback before it shows. Community owners
+ * and admins may flag channels they have not joined, whose confirmed state
+ * the card keeps itself. The welcome channel is always joined first, so its
+ * row is fixed and its own flag is left as it is.
+ */
+function StarterChannels({
+ channels,
+ welcomeChannel,
+ lifecycle,
+ active,
+ saving,
+ pending,
+ onPendingChange,
+ loading,
+ failed,
+ retry,
+}: {
+ channels: readonly Option[];
+ welcomeChannel?: Option | undefined;
+ lifecycle: ChannelLifecycleCapability;
+ active(): boolean;
+ saving: boolean;
+ pending: string | undefined;
+ onPendingChange(id: string | undefined): void;
+ loading: boolean;
+ failed: boolean;
+ /** Offered here only when nothing else on the card offers it. */
+ retry?: (() => void) | undefined;
+}) {
+ const heading = useId();
+ const [confirmed, setConfirmed] = useState>(
+ () => new Map(),
+ );
+ const [errors, setErrors] = useState>(
+ () => new Map(),
+ );
+ const operation = useRef(undefined);
+ useEffect(() => () => operation.current?.abort(), []);
+ const others = channels.filter(
+ (channel) => channel.id !== welcomeChannel?.id,
+ );
+ const busy = saving || pending !== undefined || !lifecycle.available;
+ const toggle = async (id: string, enabled: boolean) => {
+ if (operation.current || saving) return;
+ const controller = new AbortController();
+ operation.current = controller;
+ onPendingChange(id);
+ setErrors((current) => {
+ const next = new Map(current);
+ next.delete(id);
+ return next;
+ });
+ try {
+ await lifecycle.run(
+ enabled ? "auto-join-on" : "auto-join-off",
+ id,
+ controller.signal,
+ active,
+ );
+ setConfirmed((current) => new Map(current).set(id, enabled));
+ } catch (reason) {
+ if (!controller.signal.aborted)
+ setErrors((current) =>
+ new Map(current).set(id, {
+ enabled,
+ // Setting the same value again is idempotent, so retrying is the check.
+ message:
+ reason instanceof ChannelLifecycleUnconfirmed
+ ? "Saved, but not confirmed yet. Try again to check."
+ : saveError(reason),
+ }),
+ );
+ } finally {
+ operation.current = undefined;
+ // The form may outlive this list, as when a check finds no support.
+ onPendingChange(undefined);
+ }
+ };
+ return (
+
+
+ {(welcomeChannel || others.length > 0) && (
+
+ {welcomeChannel && (
+ }
+ label={welcomeChannel.name}
+ description="Welcome channel · always joined"
+ checked
+ disabled
+ />
+ )}
+ {others.map((channel) => {
+ const checked = confirmed.get(channel.id) ?? channel.autoJoin;
+ const error = errors.get(channel.id);
+ // Once the flag shows what a failed toggle wanted, it is in effect.
+ const shown = error && error.enabled !== checked ? error : undefined;
+ return (
+
+ }
+ label={channel.name}
+ checked={checked}
+ disabled={busy}
+ aria-busy={pending === channel.id || undefined}
+ onCheckedChange={(enabled) =>
+ void toggle(channel.id, enabled)
+ }
+ />
+ {shown && (
+
+ {shown.message}
+
+ )}
+
+ );
+ })}
+
+ )}
+ {loading && !others.length ? (
+ Loading channels…
+ ) : (
+ !failed &&
+ !others.length && There are no other public channels yet.
+ )}
+ {failed && retry && (
+
+
+ Public channels couldn’t be loaded.
+
+
+ Retry loading channels
+
+
+ )}
+
+ );
+}
diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx
index 539c72c699..8abfd19953 100644
--- a/src/bundled/moderation/index.tsx
+++ b/src/bundled/moderation/index.tsx
@@ -1,7 +1,6 @@
import type { PluginModule } from "../../plugins/api";
import { CommunityAdmin } from "./CommunityAdmin";
import { canManageMembership } from "./membership";
-import { createWelcomeSupport } from "./welcome";
import { WelcomeSettings } from "./WelcomeSettings";
export const inject = ["relay", "settingsCards", "adminAccess", "navigation"];
@@ -63,37 +62,16 @@ export const apply: PluginModule["apply"] = (ctx) => {
/>
),
});
- // Owners and admins choose the welcome channel on relays that support it.
- const support = createWelcomeSupport(relay);
- ctx.effect(() => () => support.dispose());
+ // Owners and admins set the welcome and starter channels. The card itself
+ // keeps the welcome form to relays that support it.
ctx.settingsCards.register({
id: "welcome",
title: "Welcome",
section: "administration",
visibility: {
- snapshot: () => {
- const access = manageable();
- if (access !== true) return access;
- const state = support.snapshot();
- if (state.status === "ready") return !!state.supported;
- return state.status === "error" ? "error" : "pending";
- },
- subscribe(listener) {
- const offMembership = membership.subscribe(listener);
- const offSupport = support.subscribe(listener);
- return () => {
- offMembership();
- offSupport();
- };
- },
- ensure() {
- const releaseMembership = membership.ensure();
- const releaseSupport = support.ensure();
- return () => {
- releaseMembership();
- releaseSupport();
- };
- },
+ snapshot: manageable,
+ subscribe: membership.subscribe,
+ ensure: membership.ensure,
},
component: ({ active, community }) => (
({
const relayKey = "f".repeat(64);
const owner = "0".repeat(64);
-/** A ready connection whose relay-signed roster names the viewer an owner. */
-function relay(): RelayData {
+/** A ready connection whose relay-signed roster gives the viewer `role`. */
+function relay(role = "owner"): RelayData {
const session = {
relayAuthor: relayKey,
subscribeLive: () => () => {},
@@ -33,7 +33,7 @@ function relay(): RelayData {
created_at: 1,
content: "",
sig: "",
- tags: [["-"], ["member", owner, "owner"]],
+ tags: [["-"], ["member", owner, role]],
},
],
};
@@ -105,21 +105,23 @@ it("registers the Membership card under the key the community rail opens", async
});
it.each([
- [true, true],
- [false, false],
+ ["owner", true],
+ ["member", false],
])(
- "shows the Welcome card to owners when the relay advertises welcome channels: %s",
- async (landingChannels, visible) => {
+ "shows the Welcome card to a community %s on any relay: %s",
+ async (role, visible) => {
+ // Starter channels need no relay support; the card hides only the
+ // welcome form when the relay lacks it.
vi.mocked(communityRequest).mockResolvedValue({
policy: null,
- landingChannels,
+ landingChannels: false,
});
const root = new Context();
root.provide("pluginStatus", {
isActive: () => true,
subscribe: () => () => {},
});
- const data = relay();
+ const data = relay(role);
root.provide("relay", data);
const access = createAdminAccess(
data,
@@ -142,15 +144,11 @@ it.each([
await fiber.await();
await flush();
+ // The roster settles both cards; a member sees neither.
await vi.waitFor(() =>
- expect(cards.visibility("buzz.moderation/welcome")).toBe(visible),
- );
- expect(communityRequest).toHaveBeenCalledWith(
- "https://primary.example",
- "info",
- undefined,
- expect.any(AbortSignal),
+ expect(cards.visibility(MEMBERSHIP_SECTION)).toBe(visible),
);
+ expect(cards.visibility("buzz.moderation/welcome")).toBe(visible);
const card = cards
.snapshot()
.find((entry) => entry.key === "buzz.moderation/welcome");
diff --git a/src/bundled/moderation/welcome.ts b/src/bundled/moderation/welcome.ts
deleted file mode 100644
index 9931783b55..0000000000
--- a/src/bundled/moderation/welcome.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import {
- communityRequest,
- type CommunityInfo,
-} from "../../features/communities/api";
-import { communityFromScope } from "../../features/relay/gifs";
-import type { RelayData } from "../../features/relay/service";
-
-export type WelcomeSupport = Readonly<{
- status: "idle" | "loading" | "ready" | "error";
- /** The relay advertises the welcome-channel command. */
- supported?: boolean;
-}>;
-
-const idle: WelcomeSupport = Object.freeze({ status: "idle" });
-
-/** Whether the connected community's relay supports a welcome channel, from
- * its NIP-11 document. Each new demand reads it again, so a relay upgrade
- * shows the card the next time Settings opens. */
-export function createWelcomeSupport(relay: RelayData) {
- let value = idle;
- let key = "";
- let controller: AbortController | undefined;
- let demands = 0;
- let disposed = false;
- const listeners = new Set<() => void>();
- const publish = (next: WelcomeSupport) => {
- value = Object.freeze(next);
- for (const listener of listeners) listener();
- };
- const current = () => {
- const connection = relay.snapshot();
- const nextKey = `${connection.generation}:${connection.status === "ready" ? (connection.scope ?? "") : ""}`;
- if (nextKey !== key) {
- key = nextKey;
- controller?.abort();
- value = idle;
- }
- return connection;
- };
- const load = (fresh: boolean) => {
- const connection = current();
- if (
- disposed ||
- !demands ||
- connection.status !== "ready" ||
- !connection.scope ||
- (!fresh && (value.status === "loading" || value.status === "ready"))
- )
- return;
- const community = communityFromScope(connection.scope);
- if (!community) return;
- controller?.abort();
- const owner = new AbortController();
- controller = owner;
- const capturedKey = key;
- const retained = value.status === "ready" ? value : undefined;
- if (!retained) publish({ status: "loading" });
- communityRequest(
- community,
- "info",
- undefined,
- owner.signal,
- ).then(
- (info) => {
- if (owner.signal.aborted || capturedKey !== key || disposed) return;
- publish({ status: "ready", supported: !!info.landingChannels });
- },
- () => {
- if (owner.signal.aborted || capturedKey !== key || disposed) return;
- // A failed re-read keeps the support already shown.
- if (!retained) publish({ status: "error" });
- },
- );
- };
- const stop = relay.subscribe(() => {
- const before = key;
- current();
- for (const listener of listeners) listener();
- if (before !== key) load(false);
- });
- return {
- snapshot: () => value,
- subscribe(listener: () => void) {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- },
- ensure() {
- const reacquired = demands === 0;
- demands++;
- load(reacquired);
- let released = false;
- return () => {
- if (released) return;
- released = true;
- demands--;
- };
- },
- dispose() {
- disposed = true;
- stop();
- controller?.abort();
- listeners.clear();
- },
- };
-}
-
-export type WelcomeSupportStore = ReturnType;
diff --git a/src/features/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx
index 848f1b2dc3..782219d8ae 100644
--- a/src/features/channel-navigation/ChannelNavigationState.test.tsx
+++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx
@@ -196,7 +196,7 @@ it.each([true, false])(
await screen.findByRole("dialog", { name: "Archive channel: general" }),
).toHaveAccessibleDescription(
autoJoin
- ? `${archive} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.`
+ ? `${archive} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn it off in Settings → Community admin → Welcome first.`
: archive,
);
},
diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css
index 46222580ab..c88a4edf14 100644
--- a/src/features/communities/WelcomeCard.module.css
+++ b/src/features/communities/WelcomeCard.module.css
@@ -10,8 +10,7 @@
overflow-wrap: anywhere;
}
.body {
- margin: var(--space-1) 0 0;
- /* Plain text: line breaks are the only formatting. */
- white-space: pre-wrap;
+ margin-block-start: var(--space-1);
+ min-width: 0;
overflow-wrap: anywhere;
}
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
index 9f3a852c61..4bdde1b694 100644
--- a/src/features/communities/WelcomeCard.test.tsx
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -1,43 +1,118 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
-import { afterEach, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import { profileTarget } from "../profiles/target";
+import type { RelaySession } from "../relay/session";
import type { CommunityReader } from "./service";
import {
CommunityWelcomeCard,
DEFAULT_WELCOME_MESSAGE,
WelcomeCard,
+ WelcomeMessage,
} from "./WelcomeCard";
afterEach(cleanup);
-it("titles the welcome with the community and shows the default message", () => {
- render( );
+const channel = "11111111-1111-4111-8111-111111111111";
+const scope = `https://primary.example:${"0".repeat(64)}`;
+const person = "a".repeat(64);
+const joined = { status: "ready", channels: [] };
+const session = {
+ viewer: "0".repeat(64),
+ media: () => undefined,
+ channels: {
+ list: () => joined,
+ subscribeList: () => () => {},
+ },
+} as unknown as RelaySession;
+
+function welcome(
+ message: string | undefined,
+ options: Partial[0]> = {},
+) {
+ return (
+
+ false}
+ {...options}
+ />
+
+ );
+}
+
+it("titles the card and shows the default message", () => {
+ render(welcome(undefined));
const card = screen.getByRole("region", { name: "Welcome to Primary" });
expect(card).toHaveTextContent(DEFAULT_WELCOME_MESSAGE);
});
-it("shows a custom message as plain text", () => {
- render(all"} />);
+it("renders a custom message as Markdown, never as HTML", () => {
+ render(welcome("**Hi** all \nWelcome"));
const card = screen.getByRole("region", { name: "Welcome to Primary" });
- expect(card).toHaveTextContent("**Hi** all ");
- expect(card.querySelector("b, strong")).toBeNull();
+ expect(card.querySelector("strong")).toHaveTextContent("Hi");
+ expect(card.querySelector("b")).toBeNull();
+ // A single newline is a line break, as in messages.
+ expect(card.querySelector("br")).not.toBeNull();
+});
+
+it("opens an identity link's profile from the timeline without notifying", async () => {
+ const target = profileTarget(person);
+ const onOpenLink = vi.fn(() => true);
+ render(
+ welcome(`Ask [@Ana](${target}) anything.`, {
+ onOpenLink,
+ canOpenLink: () => true,
+ }),
+ );
+ await userEvent.click(screen.getByRole("button", { name: "View Ana profile" }));
+ expect(onOpenLink).toHaveBeenCalledWith(target);
});
-it("names the community the viewer saved, falling back to a plain title", () => {
+it("shows an identity link as an inert chip in a preview", () => {
+ const target = profileTarget(person);
+ const view = render(
+ welcome(`Ask [@Ana](${target}) anything.`, { interactive: false }),
+ );
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(
+ view.container.querySelector(`[data-profile-target="${target}"]`),
+ ).toHaveTextContent("Ana");
+ expect(view.container.textContent).not.toContain(target);
+});
+
+it("titles the default with the community the viewer saved, else a custom title", () => {
const communities = {
snapshot: () => ({
memberships: [{ id: "https://primary.example", name: "Primary" }],
}),
subscribe: () => () => {},
} as unknown as CommunityReader;
- const scope = `https://primary.example:${"0".repeat(64)}`;
const { rerender } = render(
- ,
+
+ Body
+ ,
);
expect(
screen.getByRole("region", { name: "Welcome to Primary" }),
).toBeVisible();
- rerender( );
+ rerender(
+
+ Body
+ ,
+ );
+ expect(screen.getByRole("region", { name: "Hello, newcomer" })).toBeVisible();
+ rerender(
+ Body ,
+ );
expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
});
diff --git a/src/features/communities/WelcomeCard.tsx b/src/features/communities/WelcomeCard.tsx
index 35c37b07b5..088288074c 100644
--- a/src/features/communities/WelcomeCard.tsx
+++ b/src/features/communities/WelcomeCard.tsx
@@ -1,5 +1,9 @@
-import { useId, useSyncExternalStore } from "react";
+import { useId, useSyncExternalStore, type ReactNode } from "react";
+import type { ConversationExtensions } from "../conversation/contracts";
+import { MessageMarkdown } from "../messages/MessageMarkdown";
+import { useReferenceDirectory } from "../messages/ReferenceText";
import { splitPartition } from "../relay/partition";
+import type { RelaySession } from "../relay/session";
import { communityPresentation } from "./community-icon";
import type { CommunityReader } from "./service";
import styles from "./WelcomeCard.module.css";
@@ -8,44 +12,105 @@ import styles from "./WelcomeCard.module.css";
export const DEFAULT_WELCOME_MESSAGE =
"This is a place to connect with the community and keep up with what’s happening. Explore the channels to find conversations that interest you.";
+/** Shown when the community has not set its own welcome title. */
+export function defaultWelcomeTitle(communityName?: string | undefined) {
+ return communityName ? `Welcome to ${communityName}` : "Welcome";
+}
+
/** The welcome channel's introduction; a timeline start, never a message. */
export function WelcomeCard({
- communityName,
- message,
+ title,
+ children,
level = 2,
}: {
- communityName?: string | undefined;
- /** Plain text; `undefined` shows the default. */
- message?: string | undefined;
+ title: string;
+ /** The rendered message, usually a `WelcomeMessage`. */
+ children: ReactNode;
level?: 2 | 3 | 4;
}) {
- const title = useId();
+ const id = useId();
const Heading = `h${level}` as "h2" | "h3" | "h4";
return (
-
-
- {communityName ? `Welcome to ${communityName}` : "Welcome"}
+
+
+ {title}
-
- {message ?? DEFAULT_WELCOME_MESSAGE}
-
+ {children}
);
}
+/**
+ * The welcome message: Markdown source rendered as a message body is, with
+ * its `buzz://` channel and message links and `nostr:npub` identity links.
+ * Identity links name people without notifying them. A non-interactive
+ * preview shows the same references without their actions.
+ */
+export function WelcomeMessage({
+ message,
+ session,
+ scope,
+ channelId,
+ extensions,
+ onOpenLink,
+ canOpenLink,
+ interactive = true,
+}: {
+ /** Markdown source; `undefined` shows the default. */
+ message?: string | undefined;
+ session: RelaySession;
+ scope: string;
+ /** Scopes identity names to this channel's members. */
+ channelId: string;
+ extensions?: ConversationExtensions | undefined;
+ onOpenLink(url: string): boolean;
+ canOpenLink?: ((target: string) => boolean) | undefined;
+ interactive?: boolean;
+}) {
+ const directory = useReferenceDirectory(session);
+ return (
+
+ );
+}
+
const noCommunities = () => () => {};
const noSnapshot = () => undefined;
-/** The welcome card titled with the name this viewer knows the community by. */
+/** The welcome card, titled by default with the name this viewer knows the
+ * community by. */
export function CommunityWelcomeCard({
communities,
scope,
- message,
+ title,
+ children,
}: {
communities?: CommunityReader | undefined;
/** The relay session's partition. */
scope: string;
- message?: string | undefined;
+ /** Custom title; absent shows the default. */
+ title?: string | undefined;
+ children: ReactNode;
}) {
const snapshot = useSyncExternalStore(
communities?.subscribe ?? noCommunities,
@@ -59,5 +124,9 @@ export function CommunityWelcomeCard({
} catch {
// An unscoped reader has no community presentation to show.
}
- return ;
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx
index 4df15ebd59..a0e5939187 100644
--- a/src/features/messages/MessageMarkdown.test.tsx
+++ b/src/features/messages/MessageMarkdown.test.tsx
@@ -1100,6 +1100,26 @@ it.each<[string, RenderOptions]>([
expect(view.container.textContent).toBe("Mic");
});
+it("shows an explicit profile identity as an inert chip in a preview", () => {
+ const target = profileTarget(mic);
+ const view = renderDom(
+ ,
+ );
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.queryByRole("link")).toBeNull();
+ // The chip a message would show, without its profile action.
+ const chip = view.container.querySelector("[data-mention-kind]");
+ expect(chip?.tagName).toBe("SPAN");
+ expect(chip).toHaveAttribute("data-profile-target", target);
+ expect(chip).toHaveTextContent("Mic");
+ expect(view.container.textContent).not.toContain(target);
+});
+
it.each([
"nostr:npub1invalid",
"nostr:nsec1invalid",
diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx
index 44c99f8a68..4ae065d9ba 100644
--- a/src/features/panels/service.test.tsx
+++ b/src/features/panels/service.test.tsx
@@ -52,7 +52,7 @@ it("independently unloads panels without removing the page or shared data", asyn
sync: emptySync(),
}),
});
- // Channels hands community membership to its page for auto-join settings.
+ // Channels hands community membership to its page for the first-channel setup.
root.provide("adminAccess", { membership: undefined });
// GitHub reads through gh where the host can run it; here it cannot.
root.provide("host", {});
diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts
index 0a9ef09211..b76a9387ff 100644
--- a/src/features/relay/channel-lifecycle-protocol.ts
+++ b/src/features/relay/channel-lifecycle-protocol.ts
@@ -1,7 +1,7 @@
import type { EventTemplate } from "nostr-tools";
import type { ChannelSummary } from "./contracts.ts";
import { newer, type RelayEvent } from "./events.ts";
-import { validWelcomeCommand } from "./landing.ts";
+import { validWelcomeCommand, validWelcomeTitleCommand } from "./landing.ts";
export type ChannelLifecycleAction =
| "archive"
@@ -58,11 +58,14 @@ export function lifecycleTemplate(
return event;
}
+/** The welcome's two parts; `""` restores the default for that part. */
+export type WelcomeCommand = Readonly<{ message: string; title: string }>;
+
/**
* Makes a channel the community's welcome channel with its full welcome
- * state. `""` restores the default message; the relay accepts a repeat.
+ * state. The relay accepts a repeat.
*/
-export function landingTemplate(channelId: string, welcome: string) {
+export function landingTemplate(channelId: string, welcome: WelcomeCommand) {
const event = {
kind: 9002,
created_at: Math.floor(Date.now() / 1000),
@@ -70,21 +73,31 @@ export function landingTemplate(channelId: string, welcome: string) {
tags: [
["h", lifecycleChannelId(channelId)],
["landing", "true"],
- ["welcome", welcome],
+ ["welcome", welcome.message],
+ ["welcome_title", welcome.title],
],
};
validateLifecycleTemplate(event);
return event;
}
+/** The full-state form, or the older form without a title (which keeps it). */
function landingCommand(tags: string[][]) {
+ const value = (index: number, name: string) => {
+ const tag = tags[index];
+ return tag?.length === 2 && tag[0] === name && typeof tag[1] === "string"
+ ? tag[1]
+ : undefined;
+ };
+ const message = value(2, "welcome");
+ const title = tags.length === 4 ? value(3, "welcome_title") : "";
return (
- tags.length === 3 &&
+ (tags.length === 3 || tags.length === 4) &&
JSON.stringify(tags[1]) === JSON.stringify(["landing", "true"]) &&
- tags[2]?.length === 2 &&
- tags[2][0] === "welcome" &&
- typeof tags[2][1] === "string" &&
- validWelcomeCommand(tags[2][1])
+ message !== undefined &&
+ validWelcomeCommand(message) &&
+ title !== undefined &&
+ validWelcomeTitleCommand(title)
);
}
@@ -99,16 +112,17 @@ export function validateLifecycleTemplate(event: EventTemplate): void {
!Array.isArray(event.tags) ||
!(
event.tags.length === (event.kind === 9002 ? 2 : 1) ||
- (event.kind === 9002 && event.tags.length === 3)
+ (event.kind === 9002 &&
+ (event.tags.length === 3 || event.tags.length === 4))
) ||
event.tags[0]?.length !== 2 ||
event.tags[0]?.[0] !== "h"
)
throw new Error("Invalid channel lifecycle command");
lifecycleChannelId(event.tags[0]?.[1] ?? "");
- if (event.kind === 9002 && event.tags.length === 3) {
+ if (event.kind === 9002 && event.tags.length > 2) {
if (!landingCommand(event.tags))
- throw new Error("Only the welcome channel and message may be set here");
+ throw new Error("Only the welcome channel and its welcome may be set here");
return;
}
if (
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index 0aca0cd1da..11b26c3a67 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -2200,6 +2200,23 @@ describe("auto-join switch", () => {
},
);
+ it("flags a channel the viewer has not joined, keeping it out of discovery", async () => {
+ // Community owners and admins need not belong to the channel.
+ const h = publicStream();
+ h.deny();
+ try {
+ await h.owner.capability.run("auto-join-on", id);
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({
+ consistency: "strong",
+ "#d": [id],
+ });
+ expect(h.acceptDiscovery).not.toHaveBeenCalled();
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
it("surfaces the relay's refusal and keeps an unconfirmed flag uncertain", async () => {
const h = publicStream();
try {
@@ -2226,6 +2243,7 @@ describe("auto-join switch", () => {
});
describe("welcome channel", () => {
+ const welcome = (message = "", title = "") => ({ message, title });
// The viewer is a relay owner/admin but need not belong to the channel.
function welcomeStream(tags: string[][] = [["public"]], type = "stream") {
const h = harness("member", type);
@@ -2242,8 +2260,13 @@ describe("welcome channel", () => {
h.publish.mockImplementation(async (event: RelayEvent) => {
if (event.kind !== 9002) return;
const text = event.tags.find(([key]) => key === "welcome")?.[1];
+ const title = event.tags.find(([key]) => key === "welcome_title")?.[1];
h.setEvents([
- metadata([["landing"], ...(text ? [["welcome", text]] : [])]),
+ metadata([
+ ["landing"],
+ ...(text ? [["welcome", text]] : []),
+ ...(title ? [["welcome_title", title]] : []),
+ ]),
...h.getEvents().filter((event) => event.kind !== 39000),
]);
});
@@ -2254,14 +2277,18 @@ describe("welcome channel", () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const h = welcomeStream();
try {
- await h.owner.capability.setLanding(id, "Say hi\nhere");
+ await h.owner.capability.setLanding(
+ id,
+ welcome("# Say hi\n\n\t**here**", "Hello, Acme"),
+ );
expect(h.sign.mock.calls[0]?.[0]).toEqual(
- landingTemplate(id, "Say hi\nhere"),
+ landingTemplate(id, welcome("# Say hi\n\n\t**here**", "Hello, Acme")),
);
expect(h.sign.mock.calls[0]?.[0]?.tags).toEqual([
["h", id],
["landing", "true"],
- ["welcome", "Say hi\nhere"],
+ ["welcome", "# Say hi\n\n\t**here**"],
+ ["welcome_title", "Hello, Acme"],
]);
expect(h.publish).toHaveBeenCalledOnce();
// Only signed metadata is read, and the confirmation is strong.
@@ -2279,15 +2306,22 @@ describe("welcome channel", () => {
}
});
- it("restores the default with an empty message and confirms its absence", async () => {
+ it("restores the defaults with empty values and confirms their absence", async () => {
const h = welcomeStream();
h.setEvents([
- h.metadata([["landing"], ["welcome", "Old"]]),
+ h.metadata([
+ ["landing"],
+ ["welcome", "Old"],
+ ["welcome_title", "Old title"],
+ ]),
...h.getEvents().slice(1),
]);
try {
- await h.owner.capability.setLanding(id, "");
- expect(h.sign.mock.calls[0]?.[0]?.tags[2]).toEqual(["welcome", ""]);
+ await h.owner.capability.setLanding(id, welcome());
+ expect(h.sign.mock.calls[0]?.[0]?.tags.slice(2)).toEqual([
+ ["welcome", ""],
+ ["welcome_title", ""],
+ ]);
await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
landing: true,
name: "Lobby",
@@ -2297,16 +2331,28 @@ describe("welcome channel", () => {
}
});
- it("reads the current message, ignoring one without the designation", async () => {
+ it("reads the current message and title, ignoring them without the designation", async () => {
const h = welcomeStream();
try {
- h.setEvents([h.metadata([["landing"], ["welcome", "Hi"]])]);
+ h.setEvents([
+ h.metadata([
+ ["landing"],
+ ["welcome", "Hi"],
+ ["welcome_title", "Hello"],
+ ]),
+ ]);
await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
landing: true,
welcome: "Hi",
+ welcomeTitle: "Hello",
name: "Lobby",
});
- h.setEvents([h.metadata([["welcome", "Stale"]])]);
+ h.setEvents([
+ h.metadata([
+ ["welcome", "Stale"],
+ ["welcome_title", "Stale"],
+ ]),
+ ]);
await expect(h.owner.capability.readLanding(id)).resolves.toEqual({
name: "Lobby",
});
@@ -2342,7 +2388,7 @@ describe("welcome channel", () => {
...h.getEvents().slice(1),
]);
try {
- await h.owner.capability.setLanding(id, "");
+ await h.owner.capability.setLanding(id, welcome());
expect(h.acceptDiscovery).toHaveBeenCalledOnce();
} finally {
h.owner.dispose();
@@ -2360,7 +2406,9 @@ describe("welcome channel", () => {
async (tags, type) => {
const h = welcomeStream(tags, type);
try {
- await expect(h.owner.capability.setLanding(id, "")).rejects.toThrow(
+ await expect(
+ h.owner.capability.setLanding(id, welcome()),
+ ).rejects.toThrow(
"Only active public channels that don’t expire can welcome new members.",
);
expect(h.sign).not.toHaveBeenCalled();
@@ -2370,12 +2418,22 @@ describe("welcome channel", () => {
},
);
- it.each([" ", "a".repeat(1001), "tab\there"])(
- "refuses an invalid message %j before reading or signing",
- async (text) => {
+ it.each([
+ [" ", ""],
+ ["a".repeat(4001), ""],
+ ["cr\rhere", ""],
+ ["", " "],
+ ["", "a".repeat(121)],
+ ["", "two\nlines"],
+ ["", "tab\there"],
+ ])(
+ "refuses an invalid message %j or title %j before reading or signing",
+ async (message, title) => {
const h = welcomeStream();
try {
- await expect(h.owner.capability.setLanding(id, text)).rejects.toThrow();
+ await expect(
+ h.owner.capability.setLanding(id, welcome(message, title)),
+ ).rejects.toThrow();
expect(h.read).not.toHaveBeenCalled();
expect(h.sign).not.toHaveBeenCalled();
} finally {
@@ -2393,29 +2451,37 @@ describe("welcome channel", () => {
"invalid: landing requires an open, unarchived channel without a ttl",
),
);
- await expect(h.owner.capability.setLanding(id, "")).rejects.toMatchObject(
- {
- reason:
- "invalid: landing requires an open, unarchived channel without a ttl",
- },
- );
+ await expect(
+ h.owner.capability.setLanding(id, welcome()),
+ ).rejects.toMatchObject({
+ reason:
+ "invalid: landing requires an open, unarchived channel without a ttl",
+ });
// An acknowledged write whose readback never shows the designation.
h.publish.mockImplementationOnce(async () => {});
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const pending = expect(
- h.owner.capability.setLanding(id, ""),
+ h.owner.capability.setLanding(id, welcome()),
).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed);
await vi.advanceTimersByTimeAsync(2500);
await pending;
- // A readback whose message differs is not a confirmation either.
+ // A readback whose message or title differs is not a confirmation either.
h.publish.mockImplementationOnce(async () => {
h.setEvents([h.metadata([["landing"], ["welcome", "Other"]])]);
});
const differing = expect(
- h.owner.capability.setLanding(id, "Mine"),
+ h.owner.capability.setLanding(id, welcome("Mine")),
).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed);
await vi.advanceTimersByTimeAsync(2500);
await differing;
+ h.publish.mockImplementationOnce(async () => {
+ h.setEvents([h.metadata([["landing"], ["welcome", "Mine"]])]);
+ });
+ const untitled = expect(
+ h.owner.capability.setLanding(id, welcome("Mine", "Hello")),
+ ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed);
+ await vi.advanceTimersByTimeAsync(2500);
+ await untitled;
} finally {
vi.useRealTimers();
h.owner.dispose();
@@ -2426,7 +2492,7 @@ describe("welcome channel", () => {
const h = welcomeStream();
try {
await expect(
- h.owner.capability.setLanding(id, "", undefined, () => false),
+ h.owner.capability.setLanding(id, welcome(), undefined, () => false),
).rejects.toMatchObject({ name: "AbortError" });
expect(h.publish).not.toHaveBeenCalled();
} finally {
@@ -2434,18 +2500,28 @@ describe("welcome channel", () => {
}
});
- it("admits only the exact landing command", () => {
+ it("admits only the exact landing command, with or without a title", () => {
const base = { kind: 9002, content: "", created_at: 1 };
- expect(() =>
- validateLifecycleTemplate({
- ...base,
- tags: [
- ["h", id],
- ["landing", "true"],
- ["welcome", ""],
- ],
- }),
- ).not.toThrow();
+ for (const tags of [
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", "a\tb"],
+ ["welcome_title", ""],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "Hello"],
+ ],
+ ])
+ expect(() => validateLifecycleTemplate({ ...base, tags })).not.toThrow();
for (const tags of [
[
["h", id],
@@ -2465,13 +2541,44 @@ describe("welcome channel", () => {
[
["h", id],
["landing", "true"],
- ["welcome", "a\tb"],
+ ["welcome", "a\rb"],
],
[
["h", id],
["landing", "true"],
["welcome", "", "extra"],
],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "two\nlines"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", " "],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome_title", ""],
+ ["welcome", ""],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "", "extra"],
+ ],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", ""],
+ ["welcome_title", ""],
+ ],
[
["h", id],
["landing", "true"],
diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts
index 62dcdf5bd4..b7860c8f17 100644
--- a/src/features/relay/channel-lifecycle.ts
+++ b/src/features/relay/channel-lifecycle.ts
@@ -21,6 +21,7 @@ import {
ownerAgentGrant,
type ChannelLifecycleCommand,
type ChannelLifecycleSettings,
+ type WelcomeCommand,
} from "./channel-lifecycle-protocol";
/** No automatic or same-dialog retry after a request may have reached the relay. */
@@ -33,11 +34,13 @@ export class ChannelLifecycleUnconfirmed extends Error {
}
}
-/** A channel's signed welcome state. `welcome` is absent for the default message. */
+/** A channel's signed welcome state. `welcome` and `welcomeTitle` are absent
+ * for the default message and title. */
export type ChannelLanding = Readonly<{
/** Signed metadata currently designates this channel. */
landing?: true;
welcome?: string;
+ welcomeTitle?: string;
name?: string;
}>;
@@ -72,10 +75,10 @@ export interface ChannelLifecycleCapability {
signal?: AbortSignal,
): Promise;
/** Owner/admin command: make this the community's welcome channel with
- * `welcome` as its message, `""` for the default. */
+ * `welcome` as its message and title, `""` for either default. */
setLanding(
channelId: string,
- welcome: string,
+ welcome: WelcomeCommand,
signal?: AbortSignal,
/** Checked synchronously right before publishing; false publishes nothing. */
active?: () => boolean,
@@ -479,12 +482,11 @@ export function createChannelLifecycle({
return false;
};
// The relay accepts the auto-join flag only from community owners
- // and admins, a role the channel's own records cannot show. Check
- // what signed metadata can: an active public stream channel.
+ // and admins, who need not belong to the channel; that role is not
+ // in the channel's own records. Check what signed metadata can: an
+ // active public stream channel.
const flaggable = async () => {
- assertAccess(id);
const metadata = await read([39000], id, signal);
- assertAccess(id);
const channel = lifecycleRecord(metadata, 39000, id, relayAuthor);
if (
!channel ||
@@ -571,7 +573,8 @@ export function createChannelLifecycle({
record.tags.some(([key]) => key === "auto_join") ===
(action === "auto-join-on")
) {
- acceptDiscovery([record]);
+ // Only a channel this viewer reads joins the discovery state.
+ if (canAccess(id)) acceptDiscovery([record]);
return;
}
} else if (action === "archive" || action === "unarchive") {
@@ -623,7 +626,7 @@ export function createChannelLifecycle({
},
async setLanding(
value: string,
- welcome: string,
+ welcome: WelcomeCommand,
caller?: AbortSignal,
active?: () => boolean,
) {
@@ -660,13 +663,18 @@ export function createChannelLifecycle({
throw new DOMException("Channel action cancelled", "AbortError");
scope.published = true;
await writer.publish(signed, signal);
- // Readback is the bare `landing` designation plus the stored message,
- // absent for the default.
+ // Readback is the bare `landing` designation plus the stored message
+ // and title, each absent for its default.
for (const delay of [0, 250, 750, 1500]) {
await pause(delay, signal);
const record = await readMetadata(id, signal, true);
const state = record && landingTags(record.tags);
- if (record && state?.landing && (state.welcome ?? "") === welcome) {
+ if (
+ record &&
+ state?.landing &&
+ (state.welcome ?? "") === welcome.message &&
+ (state.welcomeTitle ?? "") === welcome.title
+ ) {
if (canAccess(id)) acceptDiscovery([record]);
return;
}
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 925fb3b662..2fff06c68e 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -55,8 +55,10 @@ export type ChannelSummary = Readonly<{
autoJoin?: true;
/** Relay-signed welcome channel: new members join and open it first. */
landing?: true;
- /** Custom welcome message; only present on the welcome channel. */
+ /** Custom welcome message (Markdown source); only on the welcome channel. */
welcome?: string;
+ /** Custom welcome title; only on the welcome channel. */
+ welcomeTitle?: string;
/** Presentation-only parent from signed channel metadata; never grants access. */
parentChannelId?: string | undefined;
/** Marked Huddle room; presented through its parent conversation, not the sidebar. */
@@ -313,7 +315,7 @@ export interface ChannelQueries {
* resolves through `refer`. Empty while the directory is cold or in flight. */
openDirectory?(): readonly Pick<
ChannelSummary,
- "id" | "name" | "description" | "channelType" | "archived"
+ "id" | "name" | "description" | "channelType" | "archived" | "autoJoin"
>[];
/** The open channels to browse: a first read's pages so far as they land,
* otherwise `openDirectory`'s. Display only, like `openDirectory`. */
diff --git a/src/features/relay/discovery-landing.test.ts b/src/features/relay/discovery-landing.test.ts
index 77c3ce11df..ced7ba442e 100644
--- a/src/features/relay/discovery-landing.test.ts
+++ b/src/features/relay/discovery-landing.test.ts
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { DiscoveryState } from "./discovery";
import { keypair, metadata, roster } from "./testing";
-it("marks the welcome channel and its message from relay-signed metadata", () => {
+it("marks the welcome channel and its message and title from relay-signed metadata", () => {
const viewer = keypair(),
relay = keypair();
const state = new DiscoveryState(viewer.pubkey, relay.pubkey);
@@ -14,11 +14,13 @@ it("marks the welcome channel and its message from relay-signed metadata", () =>
metadata(relay, "general", "General", 1_700_000_001, [
["landing"],
["welcome", "Say hi"],
+ ["welcome_title", "Hello"],
]),
);
expect(state.get("general")).toMatchObject({
landing: true,
welcome: "Say hi",
+ welcomeTitle: "Hello",
});
// Moving the designation re-emits this channel without it; a surviving
@@ -26,10 +28,12 @@ it("marks the welcome channel and its message from relay-signed metadata", () =>
state.accept(
metadata(relay, "general", "General", 1_700_000_002, [
["welcome", "Say hi"],
+ ["welcome_title", "Hello"],
]),
);
expect(state.get("general")?.landing).toBeUndefined();
expect(state.get("general")?.welcome).toBeUndefined();
+ expect(state.get("general")?.welcomeTitle).toBeUndefined();
});
it("ignores a non-relay-authored landing tag", () => {
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
index 10e9b03a4d..4d6945ebf3 100644
--- a/src/features/relay/landing.test.ts
+++ b/src/features/relay/landing.test.ts
@@ -4,31 +4,58 @@ import {
landingTags,
normalizeWelcome,
validWelcomeCommand,
+ validWelcomeTitleCommand,
welcomeTextProblem,
+ welcomeTitleProblem,
} from "./landing";
describe("landingTags", () => {
- it("reads the bare designation and its custom message", () => {
+ it("reads the bare designation and its custom message and title", () => {
expect(landingTags([["d", "x"]])).toEqual({});
expect(landingTags([["landing"]])).toEqual({ landing: true });
expect(landingTags([["landing"], ["welcome", "Hi\nall"]])).toEqual({
landing: true,
welcome: "Hi\nall",
});
+ expect(
+ landingTags([
+ ["landing"],
+ ["welcome", "# Hi\n\n\t**all**"],
+ ["welcome_title", "Hello, Acme"],
+ ]),
+ ).toEqual({
+ landing: true,
+ welcome: "# Hi\n\n\t**all**",
+ welcomeTitle: "Hello, Acme",
+ });
+ // Each part is customized on its own.
+ expect(landingTags([["landing"], ["welcome_title", "Hello"]])).toEqual({
+ landing: true,
+ welcomeTitle: "Hello",
+ });
});
- it("ignores a message without the designation", () => {
+ it("ignores a message or title without the designation", () => {
// A stale tag can survive on the previous welcome channel.
- expect(landingTags([["welcome", "Old"]])).toEqual({});
+ expect(
+ landingTags([
+ ["welcome", "Old"],
+ ["welcome_title", "Old"],
+ ]),
+ ).toEqual({});
});
- it("falls back to the default for a blank, repeated or invalid message", () => {
+ it("falls back to the default for a blank, repeated or invalid message or title", () => {
for (const tags of [
[["landing"], ["welcome", ""]],
[["landing"], ["welcome", " "]],
[["landing"], ["welcome"]],
[["landing"], ["welcome", "a"], ["welcome", "b"]],
- [["landing"], ["welcome", "tab\there"]],
+ [["landing"], ["welcome", "cr\rhere"]],
+ [["landing"], ["welcome_title", ""]],
+ [["landing"], ["welcome_title", " "]],
+ [["landing"], ["welcome_title", "two\nlines"]],
+ [["landing"], ["welcome_title", "a"], ["welcome_title", "b"]],
])
expect(landingTags(tags)).toEqual({ landing: true });
});
@@ -40,16 +67,18 @@ describe("welcome text", () => {
});
it("mirrors the relay's limits in Unicode scalar values", () => {
- expect(welcomeTextProblem("é".repeat(1000))).toBeUndefined();
+ expect(welcomeTextProblem("é".repeat(4000))).toBeUndefined();
// Surrogate pairs count once, as Rust `chars()` does.
- expect(welcomeTextProblem("😀".repeat(1000))).toBeUndefined();
- expect(welcomeTextProblem("a".repeat(1001))).toBe("long");
+ expect(welcomeTextProblem("😀".repeat(4000))).toBeUndefined();
+ expect(welcomeTextProblem("a".repeat(4001))).toBe("long");
expect(welcomeTextProblem(" \n ")).toBe("empty");
// U+FEFF is not Unicode White_Space: the relay keeps it, so both hosts do.
expect(welcomeTextProblem("\uFEFF")).toBeUndefined();
expect(welcomeTextProblem("\u2003\u3000")).toBe("empty");
+ // Markdown needs line breaks and tabs for structure and code.
expect(welcomeTextProblem("line\nbreak")).toBeUndefined();
- for (const text of ["tab\t", "cr\r", "nul\u0000", "del\u007f", "c1\u0085"])
+ expect(welcomeTextProblem("- item\n\t- nested")).toBeUndefined();
+ for (const text of ["cr\r", "nul\u0000", "vt\u000b", "del\u007f", "c1\u0085"])
expect(welcomeTextProblem(text)).toBe("control");
});
@@ -60,6 +89,24 @@ describe("welcome text", () => {
});
});
+describe("welcome title", () => {
+ it("mirrors the relay's one-line limits in Unicode scalar values", () => {
+ expect(welcomeTitleProblem("é".repeat(120))).toBeUndefined();
+ expect(welcomeTitleProblem("😀".repeat(120))).toBeUndefined();
+ expect(welcomeTitleProblem("a".repeat(121))).toBe("long");
+ expect(welcomeTitleProblem(" ")).toBe("empty");
+ expect(welcomeTitleProblem("\uFEFF")).toBeUndefined();
+ for (const text of ["two\nlines", "tab\t", "cr\r", "del\u007f", "c1\u0085"])
+ expect(welcomeTitleProblem(text)).toBe("control");
+ });
+
+ it("admits the empty default marker only as a command", () => {
+ expect(validWelcomeTitleCommand("")).toBe(true);
+ expect(validWelcomeTitleCommand("Hello")).toBe(true);
+ expect(validWelcomeTitleCommand(" ")).toBe(false);
+ });
+});
+
describe("landingInfo", () => {
const id = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11";
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
index 6acbeb8f83..fd8343ca99 100644
--- a/src/features/relay/landing.ts
+++ b/src/features/relay/landing.ts
@@ -1,25 +1,40 @@
/** The relay advertises the welcome-channel command in NIP-11 under this name. */
export const LANDING_EXTENSION = "buzz-landing-channel";
/** Relay limit for a custom welcome message, in Unicode scalar values. */
-export const WELCOME_MAX_LENGTH = 1000;
+export const WELCOME_MAX_LENGTH = 4000;
+/** Relay limit for a custom welcome title, in Unicode scalar values. */
+export const WELCOME_TITLE_MAX_LENGTH = 120;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+/** The one value of a tag that appears exactly once. */
+function only(tags: readonly (readonly string[])[], name: string) {
+ const matches = tags.filter(([key]) => key === name);
+ return matches.length === 1 ? matches[0]?.[1] : undefined;
+}
+
/**
- * Projects the welcome-channel tags from relay-signed channel metadata. A
- * `welcome` tag counts only while `landing` designates the channel; a blank or
- * repeated message falls back to the default copy.
+ * Projects the welcome-channel tags from relay-signed channel metadata. The
+ * `welcome` and `welcome_title` tags count only while `landing` designates
+ * the channel; a blank, invalid or repeated value falls back to the default.
*/
export function landingTags(tags: readonly (readonly string[])[]): {
landing?: true;
welcome?: string;
+ welcomeTitle?: string;
} {
if (!tags.some(([name]) => name === "landing")) return {};
- const welcome = tags.filter(([name]) => name === "welcome");
- const text = welcome.length === 1 ? welcome[0]?.[1] : undefined;
- return text !== undefined && !welcomeTextProblem(text)
- ? { landing: true, welcome: text }
- : { landing: true };
+ const welcome = only(tags, "welcome");
+ const title = only(tags, "welcome_title");
+ return {
+ landing: true,
+ ...(welcome !== undefined && !welcomeTextProblem(welcome)
+ ? { welcome }
+ : {}),
+ ...(title !== undefined && !welcomeTitleProblem(title)
+ ? { welcomeTitle: title }
+ : {}),
+ };
}
/** Line endings normalize to `\n`; surrounding whitespace is not sent. */
@@ -27,16 +42,31 @@ export function normalizeWelcome(text: string) {
return text.replace(/\r\n?/g, "\n").trim();
}
-/** Mirrors the relay's message rules for a normalized, non-default message. */
+// Unicode White_Space, as the relay's `str::trim` sees it: unlike `trim()`
+// it keeps U+FEFF, so a value of only that passes both hosts alike.
+const blank = (text: string) => /^\p{White_Space}*$/u.test(text);
+
+/** Mirrors the relay's rules for a normalized, non-default message: Markdown
+ * source whose only control characters are line breaks and tabs. */
export function welcomeTextProblem(
text: string,
): "empty" | "long" | "control" | undefined {
- // Unicode White_Space, as the relay's `str::trim` sees it: unlike `trim()`
- // it keeps U+FEFF, so a message of only that passes both hosts alike.
- if (/^\p{White_Space}*$/u.test(text)) return "empty";
+ if (blank(text)) return "empty";
if ([...text].length > WELCOME_MAX_LENGTH) return "long";
// eslint-disable-next-line no-control-regex
- if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(text)) return "control";
+ if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(text)) return "control";
+ return undefined;
+}
+
+/** Mirrors the relay's rules for a trimmed, non-default title: one line with
+ * no control characters at all. */
+export function welcomeTitleProblem(
+ text: string,
+): "empty" | "long" | "control" | undefined {
+ if (blank(text)) return "empty";
+ if ([...text].length > WELCOME_TITLE_MAX_LENGTH) return "long";
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(text)) return "control";
return undefined;
}
@@ -45,6 +75,11 @@ export function validWelcomeCommand(text: string) {
return text === "" || !welcomeTextProblem(text);
}
+/** The command's title is either the default marker `""` or a valid title. */
+export function validWelcomeTitleCommand(text: string) {
+ return text === "" || !welcomeTitleProblem(text);
+}
+
/** Relay support and its currently designated channel, from NIP-11. */
export function landingInfo(info: unknown): {
landingChannels: boolean;
diff --git a/src/features/relay/store-discovery.test.ts b/src/features/relay/store-discovery.test.ts
index d4d0dd7b35..bd667ef42c 100644
--- a/src/features/relay/store-discovery.test.ts
+++ b/src/features/relay/store-discovery.test.ts
@@ -1123,14 +1123,15 @@ it("merges incremental metadata and periodically replaces the full directory", a
delta.request.respond([
open("renamed", "after", start / 1000 + 1),
open("archived", "active", start / 1000 + 2, [["archived", "true"]]),
- open("new", "new-channel", start / 1000 + 3),
+ // The relay joins new members to a channel with the bare tag.
+ open("new", "new-channel", start / 1000 + 3, [["auto_join"]]),
]);
expect(await delta.search).toEqual({ channels: [], partial: false });
expect(h.channels.openDirectory?.()).toEqual([
{ id: "renamed", name: "after" },
{ id: "archived", name: "active", archived: true },
{ id: "future", name: "future" },
- { id: "new", name: "new-channel" },
+ { id: "new", name: "new-channel", autoJoin: true },
]);
clock += 30_000;
const empty = await read();
@@ -1146,7 +1147,7 @@ it("merges incremental metadata and periodically replaces the full directory", a
clock = start + 5 * 60_000;
const full = await read();
expect(full.request.filters[0]?.since).toBeUndefined();
- full.request.respond([open("new", "new-channel", start / 1000 + 3)]);
+ full.request.respond([open("new", "new-channel", start / 1000 + 4)]);
await full.search;
expect(h.channels.openDirectory?.()).toEqual([
{ id: "new", name: "new-channel" },
diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts
index 9665ccfe1d..884c119fb7 100644
--- a/src/features/relay/store.test.ts
+++ b/src/features/relay/store.test.ts
@@ -141,8 +141,23 @@ describe("channel store", () => {
]),
]);
await flush();
- expect(queries.list().channels[0]).not.toBe(landing);
- expect(queries.list().channels[0]?.welcome).toBe("Hello");
+ const welcomed = queries.list().channels[0];
+ expect(welcomed).not.toBe(landing);
+ expect(welcomed?.welcome).toBe("Hello");
+
+ // A title alone is a change too.
+ queries.refreshList?.();
+ next().respond([
+ roster(relay, "work", [viewer.pubkey]),
+ metadata(relay, "work", "Work", 1_700_000_002, [
+ ["landing"],
+ ["welcome", "Hello"],
+ ["welcome_title", "Hi, Work"],
+ ]),
+ ]);
+ await flush();
+ expect(queries.list().channels[0]).not.toBe(welcomed);
+ expect(queries.list().channels[0]?.welcomeTitle).toBe("Hi, Work");
store.dispose();
});
it("preserves summary identity on preview refreshes and detects same-length DM roster changes", () => {
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index d4377c72b8..4329eacf8c 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -451,6 +451,7 @@ export function createChannelStore(
old.autoJoin === channel.autoJoin &&
old.landing === channel.landing &&
old.welcome === channel.welcome &&
+ old.welcomeTitle === channel.welcomeTitle &&
old.channelType === channel.channelType &&
old.parentChannelId === channel.parentChannelId &&
old.huddle === channel.huddle &&
@@ -2382,6 +2383,10 @@ export function createChannelStore(
...(type === "stream" || type === "forum" ? { channelType: type } : {}),
...(tag(event, "about") ? { description: tag(event, "about") } : {}),
...(hasTag(event, "archived", "true") ? { archived: true as const } : {}),
+ // The bare tag says the relay joins new members here now.
+ ...(event.tags.some(([key]) => key === "auto_join")
+ ? { autoJoin: true as const }
+ : {}),
});
}
function openDirectoryChannels(events: readonly RelayEvent[]) {
diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx
index 0a356875d0..fcad4d3baf 100644
--- a/tests/fixtures/conversation.tsx
+++ b/tests/fixtures/conversation.tsx
@@ -320,8 +320,6 @@ ctx.provide("agents", {
snapshot: () => noAgents,
subscribe: () => () => {},
});
-// Channels hands community membership to its page for the auto-join switch; no roster here.
-ctx.provide("adminAccess", { membership: undefined });
Object.assign(window, {
conversationFixture: {
report,
diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx
index b7f75e7ade..eca6b5719c 100644
--- a/tests/fixtures/mentions.tsx
+++ b/tests/fixtures/mentions.tsx
@@ -381,8 +381,6 @@ if (channels) {
sync: emptySync(),
}),
});
- // Channels hands community membership to its page for the auto-join switch; no roster here.
- context.provide("adminAccess", { membership: undefined });
// Only the composer is mounted; the Channels page never consumes this service.
context.provide("relay", {
snapshot: () => ({ session: namedSession }),
From 91ad129a2abb61e0e334e366ace1971712bd261c Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 15:54:17 +1100
Subject: [PATCH 05/28] fix(communities): review follow-ups for the titled
welcome and starter list
Refuse U+2028 and U+2029 in the welcome title on both hosts. Admit only
the exact four-tag welcome command; the three-tag form is gone from the
TS and Rust allowlists and their tests.
Keep the shared MessageMarkdown preview behaviour: a non-interactive
render shows profile locators as before, and only WelcomeMessage opts
into the inert chip through inertProfileChips.
Never infer the default title from this viewer's name for the
community: an untouched title resends what is published, an edited one
is sent as written, and only Restore defaults sends the default. Consult
a starter row's kept confirmed state only for channels outside the
roster, and drop it once the incoming row agrees. Remove the orphaned
switch comment from the channels stylesheet.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
browser-host/relay-broker-api.test.mjs | 11 +++
docs/channels.md | 8 +-
docs/communities.md | 10 +-
src-tauri/src/relay/channel_writes.rs | 41 ++++----
src/bundled/channels/Channels.module.css | 1 -
.../moderation/WelcomeSettings.test.tsx | 88 ++++++++++++++++-
src/bundled/moderation/WelcomeSettings.tsx | 99 ++++++++++++++-----
.../relay/channel-lifecycle-protocol.ts | 9 +-
src/features/relay/channel-lifecycle.test.ts | 20 +++-
src/features/relay/landing.test.ts | 12 ++-
src/features/relay/landing.ts | 4 +-
11 files changed, 238 insertions(+), 65 deletions(-)
diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index 911ff0c71f..a1df69bd5d 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -3404,6 +3404,7 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
["h", id],
["landing", "false"],
["welcome", ""],
+ ["welcome_title", ""],
],
},
{
@@ -3413,6 +3414,16 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ
["h", id],
["landing", "true"],
["welcome", "a\rb"],
+ ["welcome_title", ""],
+ ],
+ },
+ {
+ ...template,
+ kind: 9002,
+ tags: [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
],
},
{
diff --git a/docs/channels.md b/docs/channels.md
index 2a07392eba..a6486c4144 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -583,7 +583,7 @@ That reread updates the channel list only for a channel the viewer can access.
The card's welcome form uses the same route for the welcome channel. Each save is
one `9002` carrying the full state, `landing=true`, `welcome=` and
`welcome_title=`, each empty for its default; the relay accepts a repeat of
-the current state, and keeps the title when a `welcome_title` tag is absent. Before signing
+the current state. Before signing
and again before publishing, the lifecycle owner requires fresh relay metadata
for an active, open stream channel without an expiry. It confirms the save with a
strong reread of the target's `39000`: the relay advertises a bare `landing` tag
@@ -601,9 +601,9 @@ failure still fails the command; an aborted read alone never confirms it. These
do not forcibly interrupt an in-flight native publication IPC.
Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive),
`archived=false` (Unarchive) or `auto_join=true|false` (a starter switch), and the
-welcome shape: `landing=true`, a `welcome` of 1–4,000 characters (no control
-characters except line breaks and tabs) or empty, then optionally a
-`welcome_title` of 1–120 characters (no control characters) or empty; other values,
+four-tag welcome shape: `landing=true`, a `welcome` of 1–4,000 characters (no
+control characters except line breaks and tabs) or empty, and a `welcome_title`
+of 1–120 characters (no control characters or line separators) or empty; other values,
extra tags, cross-route commands and
altered or foreign signatures are rejected. Hosts without this capability display
an unavailable notice. Native Unarchive, the starter switches and the welcome
diff --git a/docs/communities.md b/docs/communities.md
index 3be931b3e1..1594654c23 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -183,15 +183,17 @@ starts empty and must be chosen before Save. The picker lists public, unarchived
stream channels.
The title starts with the custom title or the default, "Welcome to" and the
-community's name: 1–120 characters on one line. The message starts with the
+viewer's own name for the community: 1–120 characters on one line. The message starts with the
custom text or the default. It is Markdown, rendered as messages are:
`[@Name](nostr:npub…)` identity links show the person without notifying them,
and channel or message links pasted from **Copy link** open in place. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
Both are checked against the relay's rules as the admin types. Line endings are
-normalized and both are trimmed before sending. A title or message matching the
-default is saved as the default rather than as custom text. **Restore defaults**
-resets both in the draft. A preview shows the card as members will see it, with
+normalized and both are trimmed before sending. A message matching the default
+is saved as the default rather than as custom text. The default title depends on
+who is looking, so it is never inferred: an edited title is saved as written, an
+untouched one stays as published, and **Restore defaults** resets both in the
+draft and saves the default title. A preview shows the card as members will see it, with
its links inert. Save sends the whole state and confirms it as described in
[channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
its reason and the draft is kept. A save the relay has not shown yet keeps the
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index f2c9d5862d..99d7995b31 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -95,22 +95,24 @@ fn welcome(text: &str) -> bool {
.all(|c| matches!(c, '\n' | '\t') || !c.is_control()))
}
/// The welcome title is `""` (restore the default) or 1–120 scalar values on
-/// one line with no control characters, matching the relay's rules.
+/// one line: no control characters and no Unicode line or paragraph
+/// separator, matching the relay's rules.
fn welcome_title(text: &str) -> bool {
text.is_empty()
|| (!text.trim().is_empty()
&& text.chars().count() <= 120
- && !text.chars().any(char::is_control))
+ && !text
+ .chars()
+ .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')))
}
fn lifecycle(event: &EventTemplate) -> bool {
// Owners/admins set the community's welcome channel with its full state:
- // message and title, or the older form without a title, which keeps it.
- if event.kind == 9002 && common(event) && matches!(event.tags.len(), 3 | 4) {
+ // message and title, each `""` for its default.
+ if event.kind == 9002 && common(event) && event.tags.len() == 4 {
return tag(event, 0, "h").is_some_and(uuid)
&& tag(event, 1, "landing") == Some("true")
&& tag(event, 2, "welcome").is_some_and(welcome)
- && (event.tags.len() == 3
- || tag(event, 3, "welcome_title").is_some_and(welcome_title));
+ && tag(event, 3, "welcome_title").is_some_and(welcome_title);
}
if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012)
|| !common(event)
@@ -520,7 +522,12 @@ mod boundary_tests {
"kind": 9002,
"created_at": 1700000010,
"content": "",
- "tags": [["h", channel], ["landing", "true"], ["welcome", ""]]
+ "tags": [
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", ""]
+ ]
});
let longest = "é".repeat(4000);
for text in [
@@ -547,21 +554,10 @@ mod boundary_tests {
command["tags"][2][1] = text;
assert!(validate("channel-lifecycle", &command, None).is_err());
}
- // The full-state form adds the title; the three-tag form keeps it.
- let titled = serde_json::json!({
- "kind": 9002,
- "created_at": 1700000010,
- "content": "",
- "tags": [
- ["h", channel],
- ["landing", "true"],
- ["welcome", ""],
- ["welcome_title", ""]
- ]
- });
+ // The message may span lines; the title is one line.
let longest_title = "é".repeat(120);
for title in ["", "Welcome to Acme", longest_title.as_str()] {
- let mut command = titled.clone();
+ let mut command = event.clone();
command["tags"][3][1] = serde_json::json!(title);
assert!(validate("channel-lifecycle", &command, None).is_ok());
assert!(validate("channel-details", &command, None).is_err());
@@ -572,13 +568,16 @@ mod boundary_tests {
serde_json::json!("two\nlines"),
serde_json::json!("tab\there"),
serde_json::json!("c1\u{85}"),
+ serde_json::json!("line\u{2028}sep"),
+ serde_json::json!("para\u{2029}sep"),
Value::Null,
] {
- let mut command = titled.clone();
+ let mut command = event.clone();
command["tags"][3][1] = title;
assert!(validate("channel-lifecycle", &command, None).is_err());
}
for tags in [
+ serde_json::json!([["h", channel], ["landing", "true"], ["welcome", ""]]),
serde_json::json!([
["h", channel],
["landing", "true"],
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index 9583c650fb..1d334423ca 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -822,7 +822,6 @@ details:not([open]) > summary .sectionChevron {
margin: var(--space-1) 0 0;
color: var(--text-subtle);
}
-/* The switch trails its description; an error spans the row below both. */
.settingsId {
overflow-wrap: anywhere;
}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 9e08aa6b25..d397a0118f 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -82,7 +82,8 @@ function setup({
async () => {},
);
const run = vi.fn(async () => {});
- const list = { status: "ready", channels: joined };
+ let list = { status: "ready", channels: joined };
+ const rosterListeners = new Set<() => void>();
const session = {
viewer: owner,
media: () => undefined,
@@ -90,7 +91,10 @@ function setup({
channels: {
list: () => list,
ensureList: () => () => {},
- subscribeList: () => () => {},
+ subscribeList(listener: () => void) {
+ rosterListeners.add(listener);
+ return () => rosterListeners.delete(listener);
+ },
subscribeDirectory: () => () => {},
openDirectory: () => rows,
browseDirectory: () => rows,
@@ -113,6 +117,13 @@ function setup({
return () => listeners.delete(listener);
},
} as unknown as RelayData;
+ /** The relay republished a joined channel's metadata. */
+ const setJoined = (channels: ChannelSummary[]) => {
+ list = { status: "ready", channels };
+ act(() => {
+ for (const listener of rosterListeners) listener();
+ });
+ };
/** The same community reconnects: a new session generation. */
const reconnect = () => {
snapshot = { ...snapshot, generation: snapshot.generation + 1 };
@@ -133,6 +144,7 @@ function setup({
readLanding,
setLanding,
run,
+ setJoined,
reconnect,
view,
user: userEvent.setup(),
@@ -224,7 +236,6 @@ it("requires a channel when none is designated and sends the defaults as empty",
fireEvent.change(messageField(), {
target: { value: `${DEFAULT_WELCOME_MESSAGE}\n` },
});
- fireEvent.change(titleField(), { target: { value: " Welcome to Primary " } });
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.type(channel(), "town");
await user.click(await screen.findByRole("option", { name: "Town square" }));
@@ -302,12 +313,60 @@ it("blocks titles the relay would refuse", async () => {
fireEvent.change(titleField(), { target: { value: "a\tb" } });
expect(screen.getByText("Use one line of text for the title.")).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(titleField(), { target: { value: "a\u2028b" } });
+ expect(screen.getByText("Use one line of text for the title.")).toBeVisible();
fireEvent.change(titleField(), { target: { value: "😀".repeat(120) } });
expect(screen.getByText("120 of 120 characters")).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
expect(setLanding).not.toHaveBeenCalled();
});
+it("resends a published title untouched, even one matching this viewer's default", async () => {
+ // Another viewer may know the community by another name, so the default is
+ // never inferred from the text: only Restore defaults sends it.
+ const { setLanding, user } = setup({
+ landing: {
+ landing: true,
+ welcome: "Hi there",
+ welcomeTitle: "Welcome to Primary",
+ name: "Lobby",
+ },
+ });
+ await waitFor(() => expect(titleField()).toHaveValue("Welcome to Primary"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenLastCalledWith(
+ lobby,
+ welcome("Say hello", "Welcome to Primary"),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: "Restore defaults" }));
+ expect(titleField()).toHaveValue("Welcome to Primary");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenLastCalledWith(
+ lobby,
+ welcome(""),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+ // Typed back in, the same words are a custom title.
+ fireEvent.change(titleField(), { target: { value: "Welcome to Primary" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenLastCalledWith(
+ lobby,
+ welcome("", "Welcome to Primary"),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
it("shows the relay's reason and keeps the draft when it refuses", async () => {
const { setLanding, user } = setup();
setLanding.mockRejectedValueOnce(
@@ -538,6 +597,29 @@ it("sets a starter's flag through the channel lifecycle and holds other changes
await waitFor(() => expect(starter("Town square")).not.toBeChecked());
});
+it("follows the roster for a joined channel instead of its own confirmed state", async () => {
+ const unlisted: ChannelSummary = {
+ id: "66666666-6666-4666-8666-666666666666",
+ name: "Unlisted",
+ channelType: "stream",
+ visibility: "public",
+ };
+ const { run, setJoined, user } = setup({ joined: [unlisted] });
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ await user.click(starter("Unlisted"));
+ await waitFor(() => expect(run).toHaveBeenCalledOnce());
+ // The readback republishes the joined channel; that is what the row shows.
+ await waitFor(() =>
+ expect(starter("Unlisted")).not.toHaveAttribute("aria-busy"),
+ );
+ expect(starter("Unlisted")).not.toBeChecked();
+ setJoined([{ ...unlisted, autoJoin: true }]);
+ expect(starter("Unlisted")).toBeChecked();
+ // Another admin's later change shows too; nothing kept here overrides it.
+ setJoined([unlisted]);
+ expect(starter("Unlisted")).not.toBeChecked();
+});
+
it("explains a starter change that failed beside its row", async () => {
const { run, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index b35af5e108..7d40181265 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -53,16 +53,25 @@ const TITLE_MAX = WELCOME_TITLE_MAX_LENGTH.toLocaleString("en-US");
// Every listed channel is an open stream, so each shows the public icon.
const ChannelIcon = channelIcon({ channelType: "stream" });
-/** Defaults are never stored: a draft part matching one restores it. */
+/**
+ * The default message is never stored: a draft matching it restores it. The
+ * default title is this viewer's name for the community, so it is never
+ * inferred: an untouched title resends what is published, Restore defaults
+ * (`null`) sends the default, and an edited title is sent as written.
+ */
function commandFor(
- draft: Readonly<{ text: string; title: string }>,
- defaultTitle: string,
+ draft: Readonly<{ text: string; title?: string | null }>,
+ publishedTitle: string,
): WelcomeCommand {
const text = normalizeWelcome(draft.text);
- const title = draft.title.trim();
return {
message: text === DEFAULT_WELCOME_MESSAGE ? "" : text,
- title: title === defaultTitle ? "" : title,
+ title:
+ draft.title === undefined
+ ? publishedTitle
+ : draft.title === null
+ ? ""
+ : draft.title.trim(),
};
}
@@ -141,8 +150,19 @@ type Load =
| { status: "ready"; published: Published }
| { status: "unsupported" }
| { status: "error"; error: string };
-type Option = Readonly<{ id: string; name: string; autoJoin: boolean }>;
-type Draft = Readonly<{ channelId?: string; text: string; title: string }>;
+type Option = Readonly<{
+ id: string;
+ name: string;
+ autoJoin: boolean;
+ /** In the viewer's roster, which follows the relay's metadata. */
+ joined: boolean;
+}>;
+/** `title` is absent until edited; `null` once Restore defaults is pressed. */
+type Draft = Readonly<{
+ channelId?: string;
+ text: string;
+ title?: string | null;
+}>;
/** Open, active stream channels: the directory's, joined ones it may leave
* out (such as unlisted ones), and the current welcome channel. The joined
@@ -159,6 +179,7 @@ function eligibleChannels(
id: channel.id,
name: channel.name,
autoJoin: !!channel.autoJoin,
+ joined: false,
});
for (const channel of joined)
if (
@@ -171,10 +192,16 @@ function eligibleChannels(
id: channel.id,
name: channel.name,
autoJoin: !!channel.autoJoin,
+ joined: true,
});
const id = current?.channelId;
if (id && !rows.has(id))
- rows.set(id, { id, name: current.name ?? id.slice(0, 8), autoJoin: false });
+ rows.set(id, {
+ id,
+ name: current.name ?? id.slice(0, 8),
+ autoJoin: false,
+ joined: false,
+ });
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
}
@@ -209,8 +236,6 @@ function WelcomeEditor({
// The check compares the relay's answer with the draft as it stands then.
const draftRef = useRef(draft);
draftRef.current = draft;
- const defaultTitleRef = useRef(defaultTitle);
- defaultTitleRef.current = defaultTitle;
const checkButton = useRef(null);
const messageInput = useRef(null);
const mounted = useRef(true);
@@ -257,7 +282,7 @@ function WelcomeEditor({
};
// A draft the relay now shows has landed; there is nothing to save.
const current = draftRef.current;
- const wanted = current && commandFor(current, defaultTitleRef.current);
+ const wanted = current && commandFor(current, published.title);
const landed =
!!wanted &&
current?.channelId === published.channelId &&
@@ -291,12 +316,26 @@ function WelcomeEditor({
);
const channelId = draft ? draft.channelId : published?.channelId;
const text = draft?.text ?? (published?.welcome || DEFAULT_WELCOME_MESSAGE);
- const title = draft?.title ?? (published?.title || defaultTitle);
+ const titleDraft = draft?.title;
+ const title =
+ typeof titleDraft === "string"
+ ? titleDraft
+ : titleDraft === null || !published?.title
+ ? defaultTitle
+ : published.title;
const normalized = normalizeWelcome(text);
const trimmedTitle = title.trim();
- const command = commandFor({ text, title }, defaultTitle);
+ const command = commandFor(
+ { text, ...(titleDraft !== undefined ? { title: titleDraft } : {}) },
+ published?.title ?? "",
+ );
const problem = welcomeTextProblem(normalized);
- const titleProblem = welcomeTitleProblem(trimmedTitle);
+ // Only an edited title needs checking: a published one passed the relay, and
+ // the default is never sent.
+ const titleProblem =
+ typeof titleDraft === "string"
+ ? welcomeTitleProblem(trimmedTitle)
+ : undefined;
const dirty =
!!published &&
(channelId !== published.channelId ||
@@ -322,7 +361,7 @@ function WelcomeEditor({
setDraft({
...(channelId ? { channelId } : {}),
text,
- title,
+ ...(titleDraft !== undefined ? { title: titleDraft } : {}),
...patch,
});
setSaved(false);
@@ -523,15 +562,14 @@ function WelcomeEditor({
onChange={(event) => edit({ text: event.target.value })}
/>
- {(normalized !== DEFAULT_WELCOME_MESSAGE ||
- trimmedTitle !== defaultTitle) && (
+ {(normalized !== DEFAULT_WELCOME_MESSAGE || command.title) && (
- edit({ text: DEFAULT_WELCOME_MESSAGE, title: defaultTitle })
+ edit({ text: DEFAULT_WELCOME_MESSAGE, title: null })
}
>
Restore defaults
@@ -631,10 +669,11 @@ type RowError = Readonly<{ enabled: boolean; message: string }>;
/**
* Immediate preferences: each switch is the relay's `auto_join` flag for one
- * channel, confirmed by a signed readback before it shows. Community owners
- * and admins may flag channels they have not joined, whose confirmed state
- * the card keeps itself. The welcome channel is always joined first, so its
- * row is fixed and its own flag is left as it is.
+ * channel, confirmed by a signed readback before it shows. A joined channel's
+ * row follows the roster. Community owners and admins may also flag channels
+ * they have not joined; the card keeps such a confirmed state itself until the
+ * directory shows it. The welcome channel is always joined first, so its row
+ * is fixed and its own flag is left as it is.
*/
function StarterChannels({
channels,
@@ -669,6 +708,18 @@ function StarterChannels({
);
const operation = useRef(undefined);
useEffect(() => () => operation.current?.abort(), []);
+ // A kept state is dropped once the row agrees, or turns out to be joined.
+ useEffect(() => {
+ setConfirmed((current) => {
+ let next: Map | undefined;
+ for (const [id, enabled] of current) {
+ const row = channels.find((channel) => channel.id === id);
+ if (row && (row.joined || row.autoJoin === enabled))
+ (next ??= new Map(current)).delete(id);
+ }
+ return next ?? current;
+ });
+ }, [channels]);
const others = channels.filter(
(channel) => channel.id !== welcomeChannel?.id,
);
@@ -728,7 +779,9 @@ function StarterChannels({
/>
)}
{others.map((channel) => {
- const checked = confirmed.get(channel.id) ?? channel.autoJoin;
+ const checked = channel.joined
+ ? channel.autoJoin
+ : (confirmed.get(channel.id) ?? channel.autoJoin);
const error = errors.get(channel.id);
// Once the flag shows what a failed toggle wanted, it is in effect.
const shown = error && error.enabled !== checked ? error : undefined;
diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts
index b76a9387ff..a0975b6d42 100644
--- a/src/features/relay/channel-lifecycle-protocol.ts
+++ b/src/features/relay/channel-lifecycle-protocol.ts
@@ -81,7 +81,7 @@ export function landingTemplate(channelId: string, welcome: WelcomeCommand) {
return event;
}
-/** The full-state form, or the older form without a title (which keeps it). */
+/** The full-state form: message and title, each `""` for its default. */
function landingCommand(tags: string[][]) {
const value = (index: number, name: string) => {
const tag = tags[index];
@@ -90,9 +90,9 @@ function landingCommand(tags: string[][]) {
: undefined;
};
const message = value(2, "welcome");
- const title = tags.length === 4 ? value(3, "welcome_title") : "";
+ const title = value(3, "welcome_title");
return (
- (tags.length === 3 || tags.length === 4) &&
+ tags.length === 4 &&
JSON.stringify(tags[1]) === JSON.stringify(["landing", "true"]) &&
message !== undefined &&
validWelcomeCommand(message) &&
@@ -112,8 +112,7 @@ export function validateLifecycleTemplate(event: EventTemplate): void {
!Array.isArray(event.tags) ||
!(
event.tags.length === (event.kind === 9002 ? 2 : 1) ||
- (event.kind === 9002 &&
- (event.tags.length === 3 || event.tags.length === 4))
+ (event.kind === 9002 && event.tags.length === 4)
) ||
event.tags[0]?.length !== 2 ||
event.tags[0]?.[0] !== "h"
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index 11b26c3a67..ba0ba3d3f9 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -2500,13 +2500,14 @@ describe("welcome channel", () => {
}
});
- it("admits only the exact landing command, with or without a title", () => {
+ it("admits only the exact four-tag landing command", () => {
const base = { kind: 9002, content: "", created_at: 1 };
for (const tags of [
[
["h", id],
["landing", "true"],
["welcome", ""],
+ ["welcome_title", ""],
],
[
["h", id],
@@ -2523,30 +2524,41 @@ describe("welcome channel", () => {
])
expect(() => validateLifecycleTemplate({ ...base, tags })).not.toThrow();
for (const tags of [
+ // The older form without a title is no longer admitted.
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ],
[
["h", id],
["landing", "false"],
["welcome", ""],
+ ["welcome_title", ""],
],
[
["h", id],
["welcome", ""],
["landing", "true"],
+ ["welcome_title", ""],
],
[
["h", id],
["landing", "true"],
["welcome", " "],
+ ["welcome_title", ""],
],
[
["h", id],
["landing", "true"],
["welcome", "a\rb"],
+ ["welcome_title", ""],
],
[
["h", id],
["landing", "true"],
["welcome", "", "extra"],
+ ["welcome_title", ""],
],
[
["h", id],
@@ -2554,6 +2566,12 @@ describe("welcome channel", () => {
["welcome", ""],
["welcome_title", "two\nlines"],
],
+ [
+ ["h", id],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", "line\u2028sep"],
+ ],
[
["h", id],
["landing", "true"],
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
index 4d6945ebf3..b1f18a8b52 100644
--- a/src/features/relay/landing.test.ts
+++ b/src/features/relay/landing.test.ts
@@ -96,8 +96,18 @@ describe("welcome title", () => {
expect(welcomeTitleProblem("a".repeat(121))).toBe("long");
expect(welcomeTitleProblem(" ")).toBe("empty");
expect(welcomeTitleProblem("\uFEFF")).toBeUndefined();
- for (const text of ["two\nlines", "tab\t", "cr\r", "del\u007f", "c1\u0085"])
+ for (const text of [
+ "two\nlines",
+ "tab\t",
+ "cr\r",
+ "del\u007f",
+ "c1\u0085",
+ "line\u2028sep",
+ "para\u2029sep",
+ ])
expect(welcomeTitleProblem(text)).toBe("control");
+ // The message rejects neither separator; only the title is one line.
+ expect(welcomeTextProblem("line\u2028sep")).toBeUndefined();
});
it("admits the empty default marker only as a command", () => {
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
index fd8343ca99..05f84e2774 100644
--- a/src/features/relay/landing.ts
+++ b/src/features/relay/landing.ts
@@ -59,14 +59,14 @@ export function welcomeTextProblem(
}
/** Mirrors the relay's rules for a trimmed, non-default title: one line with
- * no control characters at all. */
+ * no control characters at all, nor the Unicode line and paragraph separators. */
export function welcomeTitleProblem(
text: string,
): "empty" | "long" | "control" | undefined {
if (blank(text)) return "empty";
if ([...text].length > WELCOME_TITLE_MAX_LENGTH) return "long";
// eslint-disable-next-line no-control-regex
- if (/[\u0000-\u001f\u007f-\u009f]/.test(text)) return "control";
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) return "control";
return undefined;
}
From 3b7cec296e41c8ddf8a5b404045c528d843bba5e Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 17:26:30 +1100
Subject: [PATCH 06/28] feat(communities): searchable starter channels and a
composer editor for the welcome
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Starter channels scale to communities with thousands of channels. The
Welcome settings list only the welcome channel (fixed, always joined) and
the channels whose auto_join flag is on, each with a Remove action, plus an
"Add a channel" search over the remaining public channels. Adding or
removing runs auto-join-on/off at once through the channel lifecycle; an
added channel is listed as "Adding…" while the relay confirms it and a
removed one stays, busy, until it does. One change runs at a time, Save
waits for it, and a refusal shows beside the row or on the search.
The welcome message is edited in the composer's EditableInput with its
formatting toolbar, shortcuts and link dialog. The value stays Markdown:
published text opens as its source and round-trips unchanged, and toolbar
or typed formatting is written as the composer sends it. Pasted channel
and identity links show as chips. Enter adds a line break; there are no
mentions, attachments, GIFs or cards. The live preview, character count,
validation, Restore defaults, Cancel and Check again keep working. No
shared composer files change.
Tests were written but not run on this machine; tsc --noEmit passes.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 19 +-
docs/channels.md | 6 +-
docs/communities.md | 30 +-
src/bundled/moderation/README.md | 10 +-
.../WelcomeMessageEditor.module.css | 19 ++
.../moderation/WelcomeMessageEditor.tsx | 218 +++++++++++++
.../moderation/WelcomeSettings.test.tsx | 301 ++++++++++++++----
src/bundled/moderation/WelcomeSettings.tsx | 260 ++++++++++-----
8 files changed, 683 insertions(+), 180 deletions(-)
create mode 100644 src/bundled/moderation/WelcomeMessageEditor.module.css
create mode 100644 src/bundled/moderation/WelcomeMessageEditor.tsx
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 003c792d8c..ad5eebaa8f 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -469,16 +469,19 @@ revokes all of them, and a pairing unused for 30 days expires.
a new member's channels arrive, Messages says it is opening them. If none
arrive within a few seconds, **Try again** checks for them again. Owners and admins choose the channel and edit
the card's title and message in **Settings → Community admin → Welcome**. The
- message is Markdown, up to 4,000 characters; identity links name people
- without notifying them, and channel links can be pasted from a channel's
- **Copy link**. People already in the community keep their channels when the
- welcome channel changes.
+ message editor has the composer's formatting toolbar and shortcuts, and a
+ preview below it. The message is saved as Markdown, up to 4,000 characters,
+ and a published message opens as its Markdown source; identity links name
+ people without notifying them, and channel links can be pasted from a
+ channel's **Copy link**. People already in the community keep their channels
+ when the welcome channel changes.
- **Starter channels**: in the same **Welcome** settings, owners and admins
choose which other open channels new members join, even on relays without a
- welcome channel. A change applies to people who join afterwards, not to
- current members; in an open community it also adds returning people who were
- never in that channel. Archiving a starter channel or making it private
- pauses it until it is public again.
+ welcome channel. Only starter channels are listed, each with **Remove**;
+ **Add a channel** searches the others. A change applies to people who join
+ afterwards, not to current members; in an open community it also adds
+ returning people who were never in that channel. Archiving a starter channel
+ or making it private pauses it until it is public again.
- **Finding channels**: search's **Channels** tab lists the community's open
channels you can join, with their member counts; typing also matches channels
you're in. Opening an unjoined channel shows a preview with **Join**. Private
diff --git a/docs/channels.md b/docs/channels.md
index a6486c4144..0a9491a612 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -571,7 +571,7 @@ confirmed Delete/Leave use the existing access-loss purge. Commands use narrow
development-broker routes or the packaged native `relay_channel_sign` and
`relay_channel_publish` commands, never the message outbox or automatic replay.
The community **Welcome** settings card ([communities](communities.md#welcome-channel))
-uses the same route for its starter-channel switches, two commands without a
+uses the same route to add and remove starter channels, two commands without a
confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002` with
`auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an
active public stream channel before signing, whether or not the viewer is in it
@@ -600,13 +600,13 @@ still abort its signal. A rejected publication or definitive confirmation-read
failure still fails the command; an aborted read alone never confirms it. These signals
do not forcibly interrupt an in-flight native publication IPC.
Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive),
-`archived=false` (Unarchive) or `auto_join=true|false` (a starter switch), and the
+`archived=false` (Unarchive) or `auto_join=true|false` (adding or removing a starter channel), and the
four-tag welcome shape: `landing=true`, a `welcome` of 1–4,000 characters (no
control characters except line breaks and tabs) or empty, and a `welcome_title`
of 1–120 characters (no control characters or line separators) or empty; other values,
extra tags, cross-route commands and
altered or foreign signatures are rejected. Hosts without this capability display
-an unavailable notice. Native Unarchive, the starter switches and the welcome
+an unavailable notice. Native Unarchive, starter changes and the welcome
command require rebuilding/restarting the desktop
binary; updating the frontend alone does not update the Rust validator. Rust
validator and production-IPC tests cover both values, but do not establish an
diff --git a/docs/communities.md b/docs/communities.md
index 1594654c23..7b0da62bdd 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -184,9 +184,14 @@ stream channels.
The title starts with the custom title or the default, "Welcome to" and the
viewer's own name for the community: 1–120 characters on one line. The message starts with the
-custom text or the default. It is Markdown, rendered as messages are:
-`[@Name](nostr:npub…)` identity links show the person without notifying them,
-and channel or message links pasted from **Copy link** open in place. It takes
+custom text or the default. It is edited in the message composer's editor, with
+its formatting toolbar, shortcuts and link dialog; Enter adds a line break, and
+there are no mentions, attachments, GIFs or cards. The message is stored as
+Markdown and rendered as messages are: published Markdown opens as its source
+text, as when editing a sent message, and formatting applied in the editor is
+written as Markdown. `[@Name](nostr:npub…)` identity links show the person
+without notifying them, and channel or message links pasted from **Copy link**
+show as chips in the editor and open in place for members. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
Both are checked against the relay's rules as the admin types. Line endings are
normalized and both are trimmed before sending. A message matching the default
@@ -201,14 +206,17 @@ draft with Save disabled until **Check again** reads the relay's state. Archive,
Delete, Make private and expiry are not disabled for the welcome channel yet; the
relay refuses them, and that refusal is shown like any other lifecycle error.
-**Also add new members to** lists the community's open, unarchived stream
-channels, each with a switch for its starter flag. The welcome channel comes
-first, fixed on, because new members always join it; its own flag is left
-unchanged. A switch takes effect at once: it shows the relay-confirmed state, or
-the relay's refusal or a prompt to try again beside its row. One change runs at a
-time, and Save waits for it. Owners and admins can flag a channel they are not in.
-Turning a channel on adds people who join from then on, not current members; in
-an open community it also adds people who come back and have never been in that
+**Also add new members to** lists only the starter channels, so it stays short
+however many channels the community has. The welcome channel comes first, fixed,
+because new members always join it; its own flag is left unchanged. Each other
+starter channel has **Remove**, and **Add a channel** searches the community's
+open, unarchived stream channels that are not starters yet. Both take effect at
+once: an added channel is listed while the relay confirms it, and a removed one
+stays until it does. A refusal or a prompt to try again shows beside a removed
+channel's row, or on the search for an added one. One change runs at a time, and
+Save waits for it. Owners and admins can add a channel they are not in. Adding a
+channel applies to people who join from then on, not current members; in an
+open community it also adds people who come back and have never been in that
channel.
## Import from the previous desktop app
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 7fce68b117..9dcaca0006 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -20,10 +20,12 @@ for a possible return, but is hidden in the shipped UI.
On relays that support it, **Settings → Community admin → Welcome** lets an
owner or admin choose the public channel new members join and open first, and
-the title and Markdown message shown at the start of that channel. Saving moves
+the title and message shown at the start of that channel. The message editor
+has the composer's formatting toolbar and saves Markdown. Saving moves
the welcome to the chosen channel; people already in the community keep their
channels.
-Below that, **Also add new members to** turns other open channels into starter
-channels, which new members join too. Each switch applies at once, to people who
-join afterwards. The list also appears on relays without a welcome channel.
+Below that, **Also add new members to** lists the starter channels, which new
+members join too: **Add a channel** searches other open channels, and **Remove**
+takes one off. Each change applies at once, to people who join afterwards. The
+list also appears on relays without a welcome channel.
diff --git a/src/bundled/moderation/WelcomeMessageEditor.module.css b/src/bundled/moderation/WelcomeMessageEditor.module.css
new file mode 100644
index 0000000000..ca51e8153d
--- /dev/null
+++ b/src/bundled/moderation/WelcomeMessageEditor.module.css
@@ -0,0 +1,19 @@
+/* The editor and its toolbar inside the field's shared frame, which owns the
+ surface and the focus stroke. */
+.frame {
+ display: grid;
+ flex: 1;
+ align-self: stretch;
+ gap: var(--space-2);
+ min-width: 0;
+ padding-block: var(--space-control-inset) var(--space-1);
+ color: var(--text-standard);
+}
+/* About six lines, as the plain field had, growing with the message. */
+.frame > [role="textbox"] {
+ min-height: 6lh;
+ max-height: 24rem;
+}
+.frame [data-placeholder]::before {
+ color: var(--text-metadata);
+}
diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx
new file mode 100644
index 0000000000..4fdf90d13f
--- /dev/null
+++ b/src/bundled/moderation/WelcomeMessageEditor.tsx
@@ -0,0 +1,218 @@
+import {
+ memo,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+ type RefObject,
+} from "react";
+import { WelcomeMessage } from "../../features/communities/WelcomeCard";
+import { ComposerFormattingTools } from "../../features/messages/ComposerFormattingTools";
+import { ComposerLinkDialog } from "../../features/messages/ComposerLinkDialog";
+import {
+ composerMarkdownContext,
+ markdownRanges,
+ readComposerSnapshot,
+ type SourceRange,
+} from "../../features/messages/composer-document";
+import type {
+ ComposerFormat,
+ ComposerInputElement,
+ ComposerLinkEdit,
+} from "../../features/messages/composer-dom";
+import { composerMarkdown } from "../../features/messages/composer-markdown";
+import {
+ EditableInput,
+ type EditorDecoration,
+} from "../../features/messages/EditableInput";
+import {
+ mentionDraft,
+ type MentionDraft,
+} from "../../features/messages/mention-draft";
+import { messageLinkParts } from "../../features/messages/message-link-parts";
+import { profileKey } from "../../features/profiles/target";
+import { WELCOME_MAX_LENGTH } from "../../features/relay/landing";
+import { scanMarkdown } from "../../features/relay/message-content";
+import type { RelaySession } from "../../features/relay/session";
+import { Field } from "../../shared/design-system/ui/Field";
+import { InputGroup } from "../../shared/design-system/ui/InputGroup";
+import styles from "./WelcomeMessageEditor.module.css";
+
+const noOpen = () => false;
+/** Chip artwork whose props hold across keystrokes, so typing prose does not
+ * re-render every link in the message. */
+const LinkToken = memo(WelcomeMessage);
+
+/** Identity, channel, message and web links outside code: the spans the
+ * editor shows as chips, as the composer does. */
+function linkRanges(text: string, rich: readonly SourceRange[]) {
+ const blocked = [...rich, ...markdownRanges(text).literal];
+ const ranges: SourceRange[] = [];
+ const add = (start: number, end: number) => {
+ if (
+ ![...blocked, ...ranges].some(
+ (range) => start < range.end && end > range.start,
+ )
+ )
+ ranges.push({ start, end });
+ };
+ for (const link of scanMarkdown(text).links) {
+ const start = link.position?.start.offset;
+ const end = link.position?.end.offset;
+ if (
+ link.url &&
+ profileKey(link.url) &&
+ start !== undefined &&
+ end !== undefined
+ )
+ add(start, end);
+ }
+ messageLinkParts(text, undefined, add);
+ return ranges.sort((a, b) => a.start - b.start);
+}
+
+/**
+ * The welcome message in the composer's editor, with its formatting toolbar,
+ * shortcuts and link dialog. The value is Markdown source, as the relay stores
+ * it: formatting applied here is written as the composer sends it, and
+ * published Markdown opens as its source and is sent back unchanged. There
+ * are no recipients, so identity links only name people.
+ */
+export function WelcomeMessageEditor({
+ ref,
+ label,
+ description,
+ error,
+ value,
+ onChange,
+ disabled,
+ session,
+ scope,
+ channelId,
+}: {
+ ref: RefObject;
+ label: string;
+ description: ReactNode;
+ error?: string | undefined;
+ /** Markdown source. */
+ value: string;
+ onChange(value: string): void;
+ disabled: boolean;
+ session: RelaySession;
+ scope: string;
+ /** Scopes the names identity chips show to this channel's members. */
+ channelId: string;
+}) {
+ const id = useId();
+ const [draft, setDraft] = useState(() => mentionDraft(value));
+ // The Markdown the editor shows, to tell its own changes from new values.
+ const shown = useRef(value);
+ const [formats, setFormats] = useState([]);
+ const [linkEdit, setLinkEdit] = useState(null);
+ const [pasteTooLong, setPasteTooLong] = useState(false);
+ const links = useRef<{ text: string; ranges: readonly SourceRange[] }>(
+ undefined,
+ );
+ // A value from elsewhere (the published text, Cancel, Restore defaults)
+ // replaces the document; undo starts again from it.
+ useLayoutEffect(() => {
+ if (value === shown.current) return;
+ const next = mentionDraft(value);
+ shown.current = value;
+ ref.current?.reset(next);
+ setDraft(next);
+ setPasteTooLong(false);
+ }, [value, ref]);
+ const decorationsFor = (next: MentionDraft): EditorDecoration[] => {
+ const doc = readComposerSnapshot(next.document);
+ const context = doc
+ ? composerMarkdownContext(doc)
+ : { text: next.text, protected: [] };
+ if (links.current?.text !== context.text)
+ links.current = {
+ text: context.text,
+ ranges: linkRanges(context.text, context.protected),
+ };
+ return links.current.ranges.map(({ start, end }) => ({
+ start,
+ end,
+ editAsText: true,
+ content: (
+
+ ),
+ }));
+ };
+ const problem =
+ error ??
+ (pasteTooLong
+ ? "This paste would make the message too long. Shorten the text and try again."
+ : undefined);
+ return (
+ <>
+ {label}}
+ nativeLabel={false}
+ description={{description} }
+ error={problem && {problem} }
+ >
+
+
+ {
+ setDraft(next);
+ setPasteTooLong(false);
+ const markdown = composerMarkdown(next);
+ if (markdown === shown.current) return;
+ shown.current = markdown;
+ onChange(markdown);
+ }}
+ onFormatsChange={setFormats}
+ onEditLink={setLinkEdit}
+ onPasteTooLong={() => setPasteTooLong(true)}
+ />
+ ref.current?.toggleFormat(format)}
+ editLink={() => {
+ const edit = ref.current?.editLink();
+ if (edit) setLinkEdit(edit);
+ }}
+ >
+ {null}
+
+
+
+
+ {linkEdit && (
+ setLinkEdit(null)}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index d397a0118f..fb703feb0a 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -16,6 +16,9 @@ import {
type CommunityInfo,
} from "../../features/communities/api";
import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard";
+import type { ComposerInputElement } from "../../features/messages/composer-dom";
+import { composerDOMFixture } from "../../features/messages/composer-testing";
+import { profileTarget } from "../../features/profiles/target";
import {
ChannelLifecycleUnconfirmed,
type ChannelLanding,
@@ -33,6 +36,8 @@ vi.mock("../../features/communities/api", async (importOriginal) => ({
communityRequest: vi.fn(),
}));
+composerDOMFixture();
+
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -41,6 +46,7 @@ afterEach(() => {
const owner = "0".repeat(64);
const lobby = "11111111-1111-4111-8111-111111111111";
const square = "22222222-2222-4222-8222-222222222222";
+const random = "55555555-5555-4555-8555-555555555555";
const rows = [
{ id: lobby, name: "Lobby", channelType: "stream" as const },
{
@@ -50,7 +56,7 @@ const rows = [
autoJoin: true as const,
},
{
- id: "55555555-5555-4555-8555-555555555555",
+ id: random,
name: "Random",
channelType: "stream" as const,
},
@@ -154,7 +160,14 @@ function setup({
const channel = () => screen.getByRole("combobox", { name: "Welcome channel" });
const titleField = () => screen.getByRole("textbox", { name: "Title" });
const messageField = () => screen.getByRole("textbox", { name: "Message" });
-const starter = (name: string) => screen.getByRole("switch", { name });
+const starters = () =>
+ screen.getByRole("region", { name: "Also add new members to" });
+const removeLabel = (name: string) => `Remove #${name} from starter channels`;
+const remove = (name: string) =>
+ screen.getByRole("button", { name: removeLabel(name) });
+const removable = (name: string) =>
+ screen.queryByRole("button", { name: removeLabel(name) });
+const addField = () => screen.getByRole("combobox", { name: "Add a channel" });
const welcome = (message: string, title = "") => ({ message, title });
it("opens on the designated channel, its title and its message, read fresh", async () => {
@@ -195,10 +208,86 @@ it("opens on a custom title and renders the message as Markdown in the preview",
const preview = screen.getByRole("region", { name: "Hello, newcomer" });
expect(preview.querySelector("strong")).toHaveTextContent("hi");
expect(
- screen.getByText(/Markdown works\. To link a channel, use Copy link/),
+ screen.getByText(
+ /Format with the toolbar or Markdown\. To link a channel, use Copy link/,
+ ),
).toBeVisible();
});
+it("writes toolbar formatting and a pasted channel link into the message as Markdown", async () => {
+ const { setLanding, user } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ const input = messageField();
+ fireEvent.change(input, { target: { value: "" } });
+ // A channel's Copy link writes the bare link.
+ const link = `buzz://channel/${square}`;
+ act(() => {
+ input.focus();
+ document.dispatchEvent(new Event("selectionchange"));
+ fireEvent.paste(input, {
+ clipboardData: {
+ items: [],
+ getData: (type: string) => (type === "text/plain" ? link : ""),
+ },
+ });
+ });
+ // A pointer click leaves focus and the caret in the editor.
+ await user.click(screen.getByRole("button", { name: "Toggle formatting" }));
+ await user.click(screen.getByRole("button", { name: "Bold" }));
+ expect(input).toHaveFocus();
+ await user.keyboard("Join");
+ expect(input.querySelector("strong")).toHaveTextContent("Join");
+ const preview = screen.getByRole("region", { name: "Welcome to Primary" });
+ expect(preview.querySelector("strong")).toHaveTextContent("Join");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`${link} **Join**`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("opens published Markdown as its source and sends it back unchanged", async () => {
+ const markdown = [
+ "**Welcome** to _Primary_",
+ "",
+ `- Say hi in [#Town square](buzz://channel/${square})`,
+ `- Ask [@Ada](${profileTarget("a".repeat(64))}) anything`,
+ ].join("\n");
+ const { setLanding, user } = setup({
+ landing: { landing: true, welcome: markdown, name: "Lobby" },
+ });
+ await waitFor(() => expect(messageField()).toHaveValue(markdown));
+ const preview = screen.getByRole("region", { name: "Welcome to Primary" });
+ expect(preview.querySelector("strong")).toHaveTextContent("Welcome");
+ expect(preview.querySelector("em")).toHaveTextContent("Primary");
+ expect(preview.querySelectorAll("li")).toHaveLength(2);
+ expect(preview.querySelector("[data-profile-target]")).toHaveTextContent(
+ "Ada",
+ );
+ // Nothing changed yet, so there is nothing to save.
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ const input = messageField() as ComposerInputElement;
+ act(() => {
+ input.focus();
+ input.setSelectionRange(markdown.length, markdown.length);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ await user.keyboard("!");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`${markdown}!`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
it("moves the welcome to another public channel with its full state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
@@ -230,7 +319,9 @@ it("requires a channel when none is designated and sends the defaults as empty",
const { readLanding, setLanding, user } = setup({
info: { policy: null, landingChannels: true },
});
- await waitFor(() => expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE));
+ await waitFor(() =>
+ expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE),
+ );
expect(channel()).toHaveValue("");
expect(readLanding).not.toHaveBeenCalled();
fireEvent.change(messageField(), {
@@ -279,10 +370,14 @@ it("blocks messages the relay would refuse and restores the defaults in the draf
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.change(messageField(), { target: { value: "a".repeat(4001) } });
- expect(screen.getByText("Shorten the message to 4,000 characters.")).toBeVisible();
+ expect(
+ screen.getByText("Shorten the message to 4,000 characters."),
+ ).toBeVisible();
fireEvent.change(messageField(), { target: { value: "a\u0007b" } });
expect(
- screen.getByText("Remove control characters. Line breaks and tabs are fine."),
+ screen.getByText(
+ "Remove control characters. Line breaks and tabs are fine.",
+ ),
).toBeVisible();
// Unicode scalar values, not UTF-16 units, as the relay counts; tabs pass.
fireEvent.change(messageField(), {
@@ -294,9 +389,7 @@ it("blocks messages the relay would refuse and restores the defaults in the draf
await user.click(screen.getByRole("button", { name: "Restore defaults" }));
expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE);
expect(titleField()).toHaveValue("Welcome to Primary");
- expect(
- screen.queryByRole("button", { name: "Restore defaults" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Restore defaults" })).toBeNull();
expect(setLanding).not.toHaveBeenCalled();
});
@@ -309,7 +402,9 @@ it("blocks titles the relay would refuse", async () => {
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.change(titleField(), { target: { value: "a".repeat(121) } });
- expect(screen.getByText("Shorten the title to 120 characters.")).toBeVisible();
+ expect(
+ screen.getByText("Shorten the title to 120 characters."),
+ ).toBeVisible();
fireEvent.change(titleField(), { target: { value: "a\tb" } });
expect(screen.getByText("Use one line of text for the title.")).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
@@ -515,10 +610,10 @@ it("offers a retry when the current welcome cannot be read", async () => {
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
});
-it("lists the other open channels as starters, the welcome channel fixed first", async () => {
- setup({
+it("lists only the starter channels, the welcome channel fixed first", async () => {
+ const { user } = setup({
joined: [
- // Joined channels the directory leaves out are listed; others are not.
+ // Joined channels the directory leaves out are eligible; others are not.
{
id: "66666666-6666-4666-8666-666666666666",
name: "Unlisted",
@@ -536,27 +631,38 @@ it("lists the other open channels as starters, the welcome channel fixed first",
],
});
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- const group = screen.getByRole("region", { name: "Also add new members to" });
+ const group = starters();
+ // Each flagged channel is listed and removable; the welcome is fixed.
expect(
within(group)
- .getAllByRole("switch")
- .map((control) => control.getAttribute("aria-label")),
- ).toEqual(["Lobby", "Random", "Town square", "Unlisted"]);
- const fixed = starter("Lobby");
- expect(fixed).toBeChecked();
- expect(fixed).toHaveAttribute("aria-disabled", "true");
- expect(fixed).toHaveAccessibleDescription("Welcome channel · always joined");
- expect(starter("Random")).not.toBeChecked();
- expect(starter("Town square")).toBeChecked();
- expect(starter("Unlisted")).toBeChecked();
- expect(starter("Random")).not.toHaveAttribute("aria-disabled");
- expect(within(group).queryByRole("switch", { name: "Staff" })).toBeNull();
+ .getAllByRole("button", { name: /^Remove / })
+ .map((button) => button.getAttribute("aria-label")),
+ ).toEqual([removeLabel("Town square"), removeLabel("Unlisted")]);
+ const fixed = within(group).getByText("Lobby");
+ expect(
+ within(group).getByText("Welcome channel · always joined"),
+ ).toBeVisible();
+ expect(
+ fixed.compareDocumentPosition(remove("Town square")) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(removable("Lobby")).toBeNull();
+ // Channels that are not starters have no row, however many there are.
+ expect(within(group).queryByText("Random")).toBeNull();
+ expect(within(group).queryByText("Staff")).toBeNull();
expect(group).toHaveTextContent(
- "Turning a channel on adds people who join from now on",
+ "A channel you add applies to people who join from now on",
+ );
+ // The search offers the rest: neither the welcome channel nor a starter.
+ await user.click(
+ screen.getByRole("button", { name: "Browse channels to add" }),
);
+ const options = await screen.findAllByRole("option");
+ expect(options.map((option) => option.textContent)).toEqual(["Random"]);
+ expect(options[0]?.querySelector("svg")).not.toBeNull();
});
-it("sets a starter's flag through the channel lifecycle and holds other changes until it settles", async () => {
+it("adds a starter from the search through the channel lifecycle, listed while the relay confirms", async () => {
const { run, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
fireEvent.change(messageField(), { target: { value: "Say hello" } });
@@ -567,34 +673,75 @@ it("sets a starter's flag through the channel lifecycle and holds other changes
settle = resolve;
}),
);
- await user.click(starter("Random"));
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
try {
expect(run).toHaveBeenCalledWith(
"auto-join-on",
- "55555555-5555-4555-8555-555555555555",
+ random,
expect.any(AbortSignal),
expect.any(Function),
);
- // One channel command at a time, and the welcome waits for it.
- expect(starter("Random")).not.toBeChecked();
- expect(starter("Random")).toHaveAttribute("aria-busy", "true");
- expect(starter("Town square")).toHaveAttribute("aria-disabled", "true");
+ expect(addField()).toHaveValue("");
+ // The new row shows at once; one channel command at a time, and the
+ // welcome waits for it.
+ expect(within(starters()).getByText("Adding…")).toBeVisible();
+ expect(remove("Random")).toBeDisabled();
+ expect(remove("Town square")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
} finally {
await act(async () => settle());
}
- // The confirmed flag shows; the directory row is not yet republished.
- expect(starter("Random")).toBeChecked();
- expect(starter("Town square")).not.toHaveAttribute("aria-disabled");
+ // The confirmed flag keeps the row; the directory is not yet republished.
+ expect(within(starters()).queryByText("Adding…")).toBeNull();
+ expect(remove("Random")).toBeEnabled();
+ expect(remove("Town square")).toBeEnabled();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- await user.click(starter("Town square"));
- expect(run).toHaveBeenLastCalledWith(
- "auto-join-off",
- square,
- expect.any(AbortSignal),
- expect.any(Function),
+ await user.click(
+ screen.getByRole("button", { name: "Browse channels to add" }),
+ );
+ expect(await screen.findByText("No matching public channels.")).toBeVisible();
+});
+
+it("removes a starter through the channel lifecycle, keeping its row until the relay confirms", async () => {
+ const { run, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ let settle!: () => void;
+ run.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ await user.click(remove("Town square"));
+ try {
+ expect(run).toHaveBeenCalledWith(
+ "auto-join-off",
+ square,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ expect(remove("Town square")).toHaveAttribute("aria-busy", "true");
+ expect(remove("Town square")).toHaveFocus();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ } finally {
+ await act(async () => settle());
+ }
+ // The row leaves with its button, so focus moves to the search.
+ expect(removable("Town square")).toBeNull();
+ expect(addField()).toHaveFocus();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(within(starters()).getByText("No other channels yet")).toBeVisible();
+ // Removed, it can be added again.
+ await user.click(
+ screen.getByRole("button", { name: "Browse channels to add" }),
);
- await waitFor(() => expect(starter("Town square")).not.toBeChecked());
+ const options = await screen.findAllByRole("option");
+ expect(options.map((option) => option.textContent)).toEqual([
+ "Random",
+ "Town square",
+ ]);
});
it("follows the roster for a joined channel instead of its own confirmed state", async () => {
@@ -606,53 +753,65 @@ it("follows the roster for a joined channel instead of its own confirmed state",
};
const { run, setJoined, user } = setup({ joined: [unlisted] });
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- await user.click(starter("Unlisted"));
- await waitFor(() => expect(run).toHaveBeenCalledOnce());
- // The readback republishes the joined channel; that is what the row shows.
- await waitFor(() =>
- expect(starter("Unlisted")).not.toHaveAttribute("aria-busy"),
- );
- expect(starter("Unlisted")).not.toBeChecked();
+ await user.type(addField(), "unl");
+ await user.click(await screen.findByRole("option", { name: "Unlisted" }));
+ expect(run).toHaveBeenCalledOnce();
+ // Every other control is held until the command settles.
+ await waitFor(() => expect(remove("Town square")).toBeEnabled());
+ // The readback republishes the joined channel; that is what the list shows.
+ expect(removable("Unlisted")).toBeNull();
setJoined([{ ...unlisted, autoJoin: true }]);
- expect(starter("Unlisted")).toBeChecked();
+ expect(remove("Unlisted")).toBeVisible();
// Another admin's later change shows too; nothing kept here overrides it.
setJoined([unlisted]);
- expect(starter("Unlisted")).not.toBeChecked();
+ expect(removable("Unlisted")).toBeNull();
});
-it("explains a starter change that failed beside its row", async () => {
+it("explains a failed removal beside its row and a failed addition on the search", async () => {
const { run, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
run.mockRejectedValueOnce(new PublishRejected("rejected"));
- await user.click(starter("Random"));
+ await user.click(remove("Town square"));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Only community owners and admins can change this.",
);
- // The message follows its row, before the next one.
+ // The message follows its row, before the search.
expect(
- starter("Random").compareDocumentPosition(alert) &
+ remove("Town square").compareDocumentPosition(alert) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
- alert.compareDocumentPosition(starter("Town square")) &
+ alert.compareDocumentPosition(addField()) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
- expect(starter("Random")).not.toBeChecked();
- // Repeating the same value is how an unconfirmed change is checked.
+ // Repeating the same change is how an unconfirmed one is checked.
run.mockRejectedValueOnce(
new ChannelLifecycleUnconfirmed(new Error("timeout")),
);
- await user.click(starter("Random"));
+ await user.click(remove("Town square"));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Saved, but not confirmed yet. Try again to check.",
);
- await user.click(starter("Random"));
- await waitFor(() => expect(starter("Random")).toBeChecked());
+ await user.click(remove("Town square"));
+ await waitFor(() => expect(removable("Town square")).toBeNull());
+ expect(screen.queryByRole("alert")).toBeNull();
+ // A failed addition has no row, so the search explains it.
+ run.mockRejectedValueOnce(new PublishRejected("rejected"));
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "#Random: Only community owners and admins can change this.",
+ );
+ expect(removable("Random")).toBeNull();
+ // Adding it again clears the reason.
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
+ await waitFor(() => expect(remove("Random")).toBeEnabled());
expect(screen.queryByRole("alert")).toBeNull();
});
-it("holds the starter switches while the welcome saves", async () => {
+it("holds starter changes while the welcome saves", async () => {
const { setLanding, user } = setup();
let settle!: () => void;
setLanding.mockImplementationOnce(
@@ -665,11 +824,17 @@ it("holds the starter switches while the welcome saves", async () => {
fireEvent.change(messageField(), { target: { value: "Say hello" } });
await user.click(screen.getByRole("button", { name: "Save" }));
try {
- expect(starter("Random")).toHaveAttribute("aria-disabled", "true");
+ expect(remove("Town square")).toBeDisabled();
+ await user.click(
+ screen.getByRole("button", { name: "Browse channels to add" }),
+ );
+ expect(
+ await screen.findByRole("option", { name: "Random" }),
+ ).toHaveAttribute("aria-disabled", "true");
} finally {
await act(async () => settle());
}
- expect(starter("Random")).not.toHaveAttribute("aria-disabled");
+ expect(remove("Town square")).toBeEnabled();
});
it("still lists starter channels when the relay has no welcome channel", async () => {
@@ -681,8 +846,8 @@ it("still lists starter channels when the relay has no welcome channel", async (
"This community’s relay doesn’t support a welcome channel.",
),
).toBeVisible();
- await waitFor(() => expect(starter("Random")).toBeVisible());
- expect(starter("Town square")).toBeChecked();
+ await waitFor(() => expect(remove("Town square")).toBeVisible());
+ expect(addField()).toBeEnabled();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
expect(screen.queryByText("Welcome channel · always joined")).toBeNull();
expect(readLanding).not.toHaveBeenCalled();
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 7d40181265..8b4e6db851 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -1,4 +1,5 @@
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
+import type { ComposerInputElement } from "../../features/messages/composer-dom";
import {
communityRequest,
type CommunityInfo,
@@ -40,10 +41,10 @@ import { Field } from "../../shared/design-system/ui/Field";
import { Header, InlineHeader } from "../../shared/design-system/ui/Header";
import { Input } from "../../shared/design-system/ui/Input";
import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion";
+import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
-import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow";
-import { Textarea } from "../../shared/design-system/ui/Textarea";
import { ToastNotice } from "../../shared/design-system/ui/Toast";
+import { WelcomeMessageEditor } from "./WelcomeMessageEditor";
import styles from "./WelcomeSettings.module.css";
const message = (reason: unknown) =>
@@ -164,6 +165,18 @@ type Draft = Readonly<{
title?: string | null;
}>;
+/** The options whose names match `needle`, best first; all for no needle. */
+function ranked(options: readonly Option[], needle: string) {
+ if (!needle) return options;
+ return options
+ .flatMap((option) => {
+ const rank = matchName(option.name, needle)?.rank;
+ return rank === undefined ? [] : [{ option, rank }];
+ })
+ .sort((a, b) => a.rank - b.rank)
+ .map(({ option }) => option);
+}
+
/** Open, active stream channels: the directory's, joined ones it may leave
* out (such as unlisted ones), and the current welcome channel. The joined
* list is live, so its flags win. */
@@ -237,7 +250,7 @@ function WelcomeEditor({
const draftRef = useRef(draft);
draftRef.current = draft;
const checkButton = useRef(null);
- const messageInput = useRef(null);
+ const messageInput = useRef(null);
const mounted = useRef(true);
const saveController = useRef(undefined);
useEffect(() => {
@@ -344,19 +357,7 @@ function WelcomeEditor({
const selected = options.find((option) => option.id === channelId) ?? null;
// The input shows the chosen name; browsing from there lists every channel.
const needle = typed === selected?.name ? "" : typed.trim().toLowerCase();
- const matches = useMemo(
- () =>
- needle
- ? options
- .flatMap((option) => {
- const rank = matchName(option.name, needle)?.rank;
- return rank === undefined ? [] : [{ option, rank }];
- })
- .sort((a, b) => a.rank - b.rank)
- .map(({ option }) => option)
- : options,
- [options, needle],
- );
+ const matches = useMemo(() => ranked(options, needle), [options, needle]);
const edit = (patch: Partial) => {
setDraft({
...(channelId ? { channelId } : {}),
@@ -379,7 +380,6 @@ function WelcomeEditor({
lifecycle={lifecycle}
active={() => mounted.current && active()}
saving={saving}
- pending={toggling}
onPendingChange={setToggling}
loading={directory.loading}
failed={!!directory.error}
@@ -540,12 +540,13 @@ function WelcomeEditor({
onChange={(event) => edit({ title: event.target.value })}
/>
-
- Markdown works. To link a channel, use Copy link in its
- menu and paste the link here.{" "}
+ Format with the toolbar or Markdown. To link a channel, use
+ Copy link in its menu and paste the link here.{" "}
{[...normalized].length.toLocaleString("en-US")} of {MAX}{" "}
characters
@@ -553,15 +554,13 @@ function WelcomeEditor({
>
}
error={problem && problems[problem]}
- >
- edit({ text: event.target.value })}
- />
-
+ value={text}
+ onChange={(body) => edit({ text: body })}
+ disabled={disabled}
+ session={session}
+ scope={scope}
+ channelId={channelId ?? ""}
+ />
{(normalized !== DEFAULT_WELCOME_MESSAGE || command.title) && (
;
+/** A starter's flag as this card knows it: a joined channel's follows the
+ * roster; another's, the change confirmed here until the directory shows it. */
+const starter = (channel: Option, confirmed: ReadonlyMap) =>
+ channel.joined
+ ? channel.autoJoin
+ : (confirmed.get(channel.id) ?? channel.autoJoin);
/**
- * Immediate preferences: each switch is the relay's `auto_join` flag for one
- * channel, confirmed by a signed readback before it shows. A joined channel's
- * row follows the roster. Community owners and admins may also flag channels
- * they have not joined; the card keeps such a confirmed state itself until the
- * directory shows it. The welcome channel is always joined first, so its row
- * is fixed and its own flag is left as it is.
+ * Connected objects: the channels whose relay `auto_join` flag is on, each
+ * removable at once, and a search that adds another. Only flagged channels are
+ * listed, so the list stays short however many channels the community has.
+ * Each change shows once a signed readback confirms it. Community owners and
+ * admins may also flag channels they have not joined. The welcome channel is
+ * always joined first, so its row is fixed and its own flag is left as it is.
*/
function StarterChannels({
channels,
@@ -681,7 +685,6 @@ function StarterChannels({
lifecycle,
active,
saving,
- pending,
onPendingChange,
loading,
failed,
@@ -692,7 +695,6 @@ function StarterChannels({
lifecycle: ChannelLifecycleCapability;
active(): boolean;
saving: boolean;
- pending: string | undefined;
onPendingChange(id: string | undefined): void;
loading: boolean;
failed: boolean;
@@ -700,12 +702,22 @@ function StarterChannels({
retry?: (() => void) | undefined;
}) {
const heading = useId();
+ const addInput = useRef(null);
+ const [typed, setTyped] = useState("");
+ // The change the relay is confirming. The session runs one channel command
+ // at a time, so Save waits for it and it waits for Save.
+ const [change, setChange] =
+ useState>();
const [confirmed, setConfirmed] = useState>(
() => new Map(),
);
- const [errors, setErrors] = useState>(
+ // Failed removals, beside their rows; a failed addition has no row, so its
+ // reason shows on the add control.
+ const [errors, setErrors] = useState>(
() => new Map(),
);
+ const [addError, setAddError] =
+ useState>();
const operation = useRef(undefined);
useEffect(() => () => operation.current?.abort(), []);
// A kept state is dropped once the row agrees, or turns out to be joined.
@@ -720,15 +732,37 @@ function StarterChannels({
return next ?? current;
});
}, [channels]);
- const others = channels.filter(
- (channel) => channel.id !== welcomeChannel?.id,
- );
- const busy = saving || pending !== undefined || !lifecycle.available;
- const toggle = async (id: string, enabled: boolean) => {
+ // A channel being added shows while the relay confirms it, as one being
+ // removed stays until it has.
+ const { starters, addable } = useMemo(() => {
+ const starters: Option[] = [];
+ const addable: Option[] = [];
+ for (const channel of channels)
+ if (channel.id !== welcomeChannel?.id)
+ (starter(channel, confirmed) || channel.id === change?.id
+ ? starters
+ : addable
+ ).push(channel);
+ return { starters, addable };
+ }, [channels, welcomeChannel?.id, confirmed, change?.id]);
+ const needle = typed.trim().toLowerCase();
+ const matches = useMemo(() => ranked(addable, needle), [addable, needle]);
+ const failedAdd =
+ addError && !starters.some((channel) => channel.id === addError.id)
+ ? `#${channels.find((channel) => channel.id === addError.id)?.name ?? addError.id.slice(0, 8)}: ${addError.message}`
+ : undefined;
+ const busy = saving || change !== undefined || !lifecycle.available;
+ const toggle = async (
+ { id }: Option,
+ enabled: boolean,
+ trigger?: HTMLElement,
+ ) => {
if (operation.current || saving) return;
const controller = new AbortController();
operation.current = controller;
+ setChange({ id, enabled });
onPendingChange(id);
+ if (enabled) setAddError(undefined);
setErrors((current) => {
const next = new Map(current);
next.delete(id);
@@ -741,83 +775,137 @@ function StarterChannels({
controller.signal,
active,
);
+ // The removed row leaves with its button; focus stays in the list.
+ if (trigger && document.activeElement === trigger)
+ addInput.current?.focus({ preventScroll: true });
setConfirmed((current) => new Map(current).set(id, enabled));
} catch (reason) {
- if (!controller.signal.aborted)
- setErrors((current) =>
- new Map(current).set(id, {
- enabled,
- // Setting the same value again is idempotent, so retrying is the check.
- message:
- reason instanceof ChannelLifecycleUnconfirmed
- ? "Saved, but not confirmed yet. Try again to check."
- : saveError(reason),
- }),
- );
+ if (controller.signal.aborted) return;
+ // Setting the same value again is idempotent, so retrying is the check.
+ const message =
+ reason instanceof ChannelLifecycleUnconfirmed
+ ? "Saved, but not confirmed yet. Try again to check."
+ : saveError(reason);
+ if (enabled) setAddError({ id, message });
+ else setErrors((current) => new Map(current).set(id, message));
} finally {
operation.current = undefined;
+ setChange(undefined);
// The form may outlive this list, as when a check finds no support.
onPendingChange(undefined);
}
};
+ const empty = starters.length
+ ? undefined
+ : loading
+ ? "Loading channels…"
+ : failed
+ ? undefined
+ : welcomeChannel
+ ? "No other channels yet"
+ : "No channels yet";
return (
- {(welcomeChannel || others.length > 0) && (
+ {(welcomeChannel || starters.length > 0 || empty) && (
{welcomeChannel && (
- }
- label={welcomeChannel.name}
- description="Welcome channel · always joined"
- checked
- disabled
+ title={welcomeChannel.name}
+ subtitle="Welcome channel · always joined"
/>
)}
- {others.map((channel) => {
- const checked = channel.joined
- ? channel.autoJoin
- : (confirmed.get(channel.id) ?? channel.autoJoin);
+ {starters.map((channel) => {
+ const adding = change?.id === channel.id && change.enabled;
+ const removing = change?.id === channel.id && !change.enabled;
const error = errors.get(channel.id);
- // Once the flag shows what a failed toggle wanted, it is in effect.
- const shown = error && error.enabled !== checked ? error : undefined;
return (
- }
- label={channel.name}
- checked={checked}
- disabled={busy}
- aria-busy={pending === channel.id || undefined}
- onCheckedChange={(enabled) =>
- void toggle(channel.id, enabled)
+ title={channel.name}
+ subtitle={adding ? "Adding…" : undefined}
+ trailing={
+
+ void toggle(channel, false, event.currentTarget)
+ }
+ >
+ Remove
+
}
/>
- {shown && (
+ {error && (
- {shown.message}
+ {error}
)}
);
})}
+ {empty && }
)}
- {loading && !others.length ? (
- Loading channels…
- ) : (
- !failed &&
- !others.length && There are no other public channels yet.
- )}
+
+ items={addable}
+ filteredItems={matches}
+ value={null}
+ inputValue={typed}
+ onInputValueChange={(next, details) => {
+ if (
+ details.reason === "input-change" ||
+ details.reason === "input-clear"
+ )
+ setTyped(next);
+ }}
+ onValueChange={(option) => {
+ if (!option) return;
+ setTyped("");
+ void toggle(option, true);
+ }}
+ itemToStringLabel={(option) => option.name}
+ itemToStringValue={(option) => option.id}
+ isItemEqualToValue={(a, b) => a.id === b.id}
+ disabled={!lifecycle.available}
+ autoHighlight
+ >
+
+
+
+ {(option: Option) => (
+
+ }
+ label={option.name}
+ />
+
+ )}
+
+
+
{failed && retry && (
-
- Public channels couldn’t be loaded.
-
Retry loading channels
From c54c96290eb4fa3ec1739dee4ace7f02f37221b3 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 17:47:12 +1100
Subject: [PATCH 07/28] fix(communities): announce starter changes and cap
channel browsing in Welcome settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-up for the searchable starter channels and welcome editor.
Adding or removing a starter channel now shows an "Added #name" or
"Removed #name" success toast, the same ToastNotice Save uses, which
Sonner announces politely. The next change clears it. The starter
list's "Loading channels…" line is a status again, and its empty line
now reads "No other channels added yet" (or "No channels added yet"
with no welcome channel), so it no longer suggests the community has no
channels.
With an empty query, the welcome channel picker and "Add a channel"
list the first 50 channels by name and end with "Showing the first 50
channels. Type a name to find more." A search still ranks every
channel. The help guide mentions the limit.
Biome lint passes again: the confirmed-state copy-on-write is an
explicit block, and the dead exhaustive-deps suppression on the welcome
read is gone. A comment on linkRanges records that, unlike the
composer, the editor shows no chips for plain #name/@Name references or
:shortcode: emoji, so the preview may show references the editor leaves
as plain text.
New tests, written but not run: Bold on one word inside published
Markdown changes only that word, and both pickers cap an unfiltered
browse, show the hint, and find channels past the cap by name. The add
and remove tests now assert the toasts and the empty line's status.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 3 +-
.../moderation/WelcomeMessageEditor.tsx | 10 +-
.../moderation/WelcomeSettings.test.tsx | 99 +++++++++++++++++--
src/bundled/moderation/WelcomeSettings.tsx | 56 +++++++++--
4 files changed, 147 insertions(+), 21 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index ad5eebaa8f..71ba2b4ca0 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -478,7 +478,8 @@ revokes all of them, and a pairing unused for 30 days expires.
- **Starter channels**: in the same **Welcome** settings, owners and admins
choose which other open channels new members join, even on relays without a
welcome channel. Only starter channels are listed, each with **Remove**;
- **Add a channel** searches the others. A change applies to people who join
+ **Add a channel** searches the others. Until you type a name, this picker and
+ the welcome channel picker list the first 50 channels. A change applies to people who join
afterwards, not to current members; in an open community it also adds
returning people who were never in that channel. Archiving a starter channel
or making it private pauses it until it is public again.
diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx
index 4fdf90d13f..ebea1375d6 100644
--- a/src/bundled/moderation/WelcomeMessageEditor.tsx
+++ b/src/bundled/moderation/WelcomeMessageEditor.tsx
@@ -44,8 +44,14 @@ const noOpen = () => false;
* re-render every link in the message. */
const LinkToken = memo(WelcomeMessage);
-/** Identity, channel, message and web links outside code: the spans the
- * editor shows as chips, as the composer does. */
+/**
+ * Identity, channel, message and web links outside code: the spans the editor
+ * shows as chips, as the composer does. This is deliberately narrower than
+ * `RichComposerInput`'s `decorationsFor`: plain `#name` and `@Name`
+ * references and `:shortcode:` emoji are not chips. The preview and card
+ * render through `MessageMarkdown`, so they may show a reference that stays
+ * plain text here.
+ */
function linkRanges(text: string, rich: readonly SourceRange[]) {
const blocked = [...rich, ...markdownRanges(text).literal];
const ranges: SourceRange[] = [];
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index fb703feb0a..43d9d4825b 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -77,11 +77,13 @@ function setup({
info = { policy: null, landingChannels: true, landingChannel: lobby },
landing = { landing: true, welcome: "Hi there", name: "Lobby" },
joined = [],
+ directory = rows,
}: {
info?: CommunityInfo;
landing?: ChannelLanding | undefined;
/** The viewer's own channels, beside the directory. */
joined?: ChannelSummary[];
+ directory?: typeof rows;
} = {}) {
const readLanding = vi.fn(async () => landing);
const setLanding = vi.fn(
@@ -102,8 +104,8 @@ function setup({
return () => rosterListeners.delete(listener);
},
subscribeDirectory: () => () => {},
- openDirectory: () => rows,
- browseDirectory: () => rows,
+ openDirectory: () => directory,
+ browseDirectory: () => directory,
readDirectory: async () => ({ partial: false }),
directoryComplete: () => true,
},
@@ -250,13 +252,15 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
);
});
+const publishedMarkdown = [
+ "**Welcome** to _Primary_",
+ "",
+ `- Say hi in [#Town square](buzz://channel/${square})`,
+ `- Ask [@Ada](${profileTarget("a".repeat(64))}) anything`,
+].join("\n");
+
it("opens published Markdown as its source and sends it back unchanged", async () => {
- const markdown = [
- "**Welcome** to _Primary_",
- "",
- `- Say hi in [#Town square](buzz://channel/${square})`,
- `- Ask [@Ada](${profileTarget("a".repeat(64))}) anything`,
- ].join("\n");
+ const markdown = publishedMarkdown;
const { setLanding, user } = setup({
landing: { landing: true, welcome: markdown, name: "Lobby" },
});
@@ -288,6 +292,40 @@ it("opens published Markdown as its source and sends it back unchanged", async (
);
});
+it("bolds one word inside published Markdown and keeps the rest of the source", async () => {
+ const markdown = publishedMarkdown;
+ const { setLanding, user } = setup({
+ landing: { landing: true, welcome: markdown, name: "Lobby" },
+ });
+ await waitFor(() => expect(messageField()).toHaveValue(markdown));
+ const input = messageField() as ComposerInputElement;
+ const word = "anything";
+ const start = markdown.indexOf(word);
+ act(() => {
+ input.focus();
+ input.setSelectionRange(start, start + word.length);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ // A pointer click keeps the selection in the editor.
+ await user.click(screen.getByRole("button", { name: "Toggle formatting" }));
+ await user.click(screen.getByRole("button", { name: "Bold" }));
+ expect(input).toHaveFocus();
+ expect(
+ [...input.querySelectorAll("strong")].map((node) => node.textContent),
+ ).toContain(word);
+ // Only the selected word changes; the emphasis, list and links around it
+ // are sent as they were published.
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(markdown.replace(word, `**${word}**`)),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
it("moves the welcome to another public channel with its full state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
@@ -662,6 +700,45 @@ it("lists only the starter channels, the welcome channel fixed first", async ()
expect(options[0]?.querySelector("svg")).not.toBeNull();
});
+it("browses the first 50 channels by name in both pickers and finds the rest by searching", async () => {
+ // Sixty more public channels, each sorting before the fixture's own.
+ const many = Array.from({ length: 60 }, (_, index) => {
+ const n = String(index + 1).padStart(2, "0");
+ return {
+ id: `aaaaaa${n}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`,
+ name: `Channel ${n}`,
+ channelType: "stream" as const,
+ };
+ });
+ const { user } = setup({ directory: [...rows, ...many] });
+ const hint = "Showing the first 50 channels. Type a name to find more.";
+ const names = () =>
+ screen.getAllByRole("option").map((option) => option.textContent);
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ // The welcome channel picker: 63 eligible channels, the first 50 shown.
+ await user.click(screen.getByRole("button", { name: "Browse channels" }));
+ await waitFor(() => expect(names()).toHaveLength(50));
+ expect(names()[0]).toBe("Channel 01");
+ expect(names()[49]).toBe("Channel 50");
+ expect(screen.getByText(hint)).toBeVisible();
+ // A search covers every channel, including those past the first 50.
+ await user.clear(channel());
+ await user.type(channel(), "town");
+ await waitFor(() => expect(names()).toEqual(["Town square"]));
+ expect(screen.queryByText(hint)).toBeNull();
+ await user.keyboard("{Escape}");
+ // The starter search: 61 addable channels, without the welcome or a starter.
+ await user.click(
+ screen.getByRole("button", { name: "Browse channels to add" }),
+ );
+ await waitFor(() => expect(names()).toHaveLength(50));
+ expect(names()[0]).toBe("Channel 01");
+ expect(screen.getByText(hint)).toBeVisible();
+ await user.type(addField(), "random");
+ await waitFor(() => expect(names()).toEqual(["Random"]));
+ expect(screen.queryByText(hint)).toBeNull();
+});
+
it("adds a starter from the search through the channel lifecycle, listed while the relay confirms", async () => {
const { run, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
@@ -693,6 +770,7 @@ it("adds a starter from the search through the channel lifecycle, listed while t
await act(async () => settle());
}
// The confirmed flag keeps the row; the directory is not yet republished.
+ expect(await screen.findByText("Added #Random")).toBeInTheDocument();
expect(within(starters()).queryByText("Adding…")).toBeNull();
expect(remove("Random")).toBeEnabled();
expect(remove("Town square")).toBeEnabled();
@@ -732,7 +810,10 @@ it("removes a starter through the channel lifecycle, keeping its row until the r
expect(removable("Town square")).toBeNull();
expect(addField()).toHaveFocus();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- expect(within(starters()).getByText("No other channels yet")).toBeVisible();
+ expect(await screen.findByText("Removed #Town square")).toBeInTheDocument();
+ expect(within(starters()).getByRole("status")).toHaveTextContent(
+ "No other channels added yet",
+ );
// Removed, it can be added again.
await user.click(
screen.getByRole("button", { name: "Browse channels to add" }),
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 8b4e6db851..31ff304f8e 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -165,9 +165,14 @@ type Draft = Readonly<{
title?: string | null;
}>;
-/** The options whose names match `needle`, best first; all for no needle. */
+/** Browsing with no search lists this many channels by name; a search covers
+ * every channel, so a community with thousands still opens a short list. */
+const BROWSE_LIMIT = 50;
+
+/** The options whose names match `needle`, best first; for no needle, the
+ * first {@link BROWSE_LIMIT} by name. */
function ranked(options: readonly Option[], needle: string) {
- if (!needle) return options;
+ if (!needle) return options.slice(0, BROWSE_LIMIT);
return options
.flatMap((option) => {
const rank = matchName(option.name, needle)?.rank;
@@ -177,6 +182,19 @@ function ranked(options: readonly Option[], needle: string) {
.map(({ option }) => option);
}
+/** Follows the results of a browse that {@link ranked} cut short. Like the
+ * popup's empty line it stays mounted while the popup is open, so clearing a
+ * search announces it. */
+function BrowseLimit({ capped }: { capped: boolean }) {
+ return (
+
+ {capped
+ ? `Showing the first ${BROWSE_LIMIT} channels. Type a name to find more.`
+ : null}
+
+ );
+}
+
/** Open, active stream channels: the directory's, joined ones it may leave
* out (such as unlisted ones), and the current welcome channel. The joined
* list is live, so its flags win. */
@@ -261,7 +279,6 @@ function WelcomeEditor({
};
}, []);
// Each open reads the live designation, then its signed welcome.
- // biome-ignore lint/correctness/useExhaustiveDependencies: An explicit retry reruns this finite read.
useEffect(() => {
const reading = new AbortController();
// A check keeps the published form in view; only the first read waits.
@@ -520,6 +537,9 @@ function WelcomeEditor({
)}
+ BROWSE_LIMIT}
+ />
{directory.error && (
@@ -718,6 +738,8 @@ function StarterChannels({
);
const [addError, setAddError] =
useState>();
+ // The last confirmed change, announced until the next one starts.
+ const [notice, setNotice] = useState();
const operation = useRef(undefined);
useEffect(() => () => operation.current?.abort(), []);
// A kept state is dropped once the row agrees, or turns out to be joined.
@@ -726,8 +748,10 @@ function StarterChannels({
let next: Map | undefined;
for (const [id, enabled] of current) {
const row = channels.find((channel) => channel.id === id);
- if (row && (row.joined || row.autoJoin === enabled))
- (next ??= new Map(current)).delete(id);
+ if (row && (row.joined || row.autoJoin === enabled)) {
+ next ??= new Map(current);
+ next.delete(id);
+ }
}
return next ?? current;
});
@@ -747,13 +771,14 @@ function StarterChannels({
}, [channels, welcomeChannel?.id, confirmed, change?.id]);
const needle = typed.trim().toLowerCase();
const matches = useMemo(() => ranked(addable, needle), [addable, needle]);
+ const capped = !needle && addable.length > BROWSE_LIMIT;
const failedAdd =
addError && !starters.some((channel) => channel.id === addError.id)
? `#${channels.find((channel) => channel.id === addError.id)?.name ?? addError.id.slice(0, 8)}: ${addError.message}`
: undefined;
const busy = saving || change !== undefined || !lifecycle.available;
const toggle = async (
- { id }: Option,
+ { id, name }: Option,
enabled: boolean,
trigger?: HTMLElement,
) => {
@@ -762,6 +787,7 @@ function StarterChannels({
operation.current = controller;
setChange({ id, enabled });
onPendingChange(id);
+ setNotice(undefined);
if (enabled) setAddError(undefined);
setErrors((current) => {
const next = new Map(current);
@@ -779,6 +805,7 @@ function StarterChannels({
if (trigger && document.activeElement === trigger)
addInput.current?.focus({ preventScroll: true });
setConfirmed((current) => new Map(current).set(id, enabled));
+ setNotice(`${enabled ? "Added" : "Removed"} #${name}`);
} catch (reason) {
if (controller.signal.aborted) return;
// Setting the same value again is idempotent, so retrying is the check.
@@ -802,8 +829,8 @@ function StarterChannels({
: failed
? undefined
: welcomeChannel
- ? "No other channels yet"
- : "No channels yet";
+ ? "No other channels added yet"
+ : "No channels added yet";
return (
);
})}
- {empty && }
+ {empty && (
+ {empty}} />
+ )}
)}
@@ -902,6 +931,7 @@ function StarterChannels({
)}
+
{failed && retry && (
@@ -911,6 +941,14 @@ function StarterChannels({
)}
+ {notice && (
+ setNotice(undefined)}
+ />
+ )}
);
}
From d2ede99b4016ad7cf4824de9986f1b41da0c8b16 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 18:32:45 +1100
Subject: [PATCH 08/28] fix(communities): open the welcome channel on a fresh
load and close the Welcome pane gaps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Messages no longer settles on the starter or first channel while any
listed channel's metadata is still pending. A welcome channel whose 39000
arrives after an auto_join channel's now opens instead of being locked out
by the earlier fallback.
The welcome channel picker caps its browse list at 50 while a channel is
selected too. Until something is typed, Base UI lists `items` rather than
`filteredItems` for a field showing the selected name, so the cap is also
set as the combobox `limit`.
A pasted npub, bare as Copy npub writes it or as a nostr: URI, becomes a
display-only `[@Name](nostr:npub…)` identity link, named from the profile
directory or a shortened npub. EditableInput gains an opt-in `pastedText`
hook for this; raw pastes and code blocks keep the copied text.
The Welcome preview renders through the conversation renderers, as the card
does, so channel links show as linked names with their icon. The moderation
plugin now injects `conversation`.
Tests cover each change but were not run in this commit.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 6 +-
docs/channels.md | 24 +--
docs/communities.md | 6 +-
.../channels/ChannelsPage.tabs.test.tsx | 75 +++++++++-
src/bundled/channels/ChannelsPage.tsx | 11 +-
.../moderation/WelcomeMessageEditor.tsx | 20 ++-
.../moderation/WelcomeSettings.test.tsx | 141 +++++++++++++++++-
src/bundled/moderation/WelcomeSettings.tsx | 15 +-
src/bundled/moderation/index.tsx | 9 +-
src/bundled/moderation/registration.test.ts | 3 +
src/features/communities/WelcomeCard.test.tsx | 8 +-
src/features/messages/EditableInput.tsx | 8 +
12 files changed, 292 insertions(+), 34 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 71ba2b4ca0..cb06e2315a 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -472,9 +472,9 @@ revokes all of them, and a pairing unused for 30 days expires.
message editor has the composer's formatting toolbar and shortcuts, and a
preview below it. The message is saved as Markdown, up to 4,000 characters,
and a published message opens as its Markdown source; identity links name
- people without notifying them, and channel links can be pasted from a
- channel's **Copy link**. People already in the community keep their channels
- when the welcome channel changes.
+ people without notifying them. Paste a channel's **Copy link** to link the
+ channel, or a person's **Copy npub** to link the person. People already in
+ the community keep their channels when the welcome channel changes.
- **Starter channels**: in the same **Welcome** settings, owners and admins
choose which other open channels new members join, even on relays without a
welcome channel. Only starter channels are listed, each with **Remove**;
diff --git a/docs/channels.md b/docs/channels.md
index 0a9491a612..7a4b4ef122 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1305,19 +1305,21 @@ With no saved selection, Messages opens the community's welcome channel if the
viewer is in it, then the first channel the relay flags `auto_join` (its starter
channel for new members), then the first non-Solo channel; an explicit empty
route bypasses all three. The welcome channel is the stream channel whose
-relay-signed `39000` carries `landing`. Its timeline starts with a welcome card
-for every viewer, even with no messages: the community name and its custom
-message, or the default one. The card is not a message, so it has no unread
-state, actions or dismissal. While the channel list or its metadata is still
-loading, Messages shows **Opening your channels…** instead of the blank
+relay-signed `39000` carries `landing`. Until every listed channel's metadata
+has arrived, any of them could be the welcome channel, so the starter and
+first-channel fallbacks wait. The welcome channel's timeline starts with a
+welcome card for every viewer, even with no messages: the community name and its
+custom message, or the default one. The card is not a message, so it has no
+unread state, actions or dismissal. While the channel list or its metadata is
+still loading, Messages shows **Opening your channels…** instead of the blank
selection view. A list with no channel to open keeps that state for up to ten
seconds, once per community visit, since the relay can still be adding a new
-member to the welcome channel; a channel that arrives meanwhile opens at once.
-A returning member with no channels left waits the same once. After that wait,
-an empty state offers **Try again**, which re-reads the channel list and waits
-the same way again. The client never joins the welcome channel for them. The relay adds people to its
-welcome and `auto_join` channels itself, welcome channel first, and the
-resulting membership hints populate the sidebar live:
+member to the welcome channel; a channel that arrives meanwhile opens at once. A
+returning member with no channels left waits the same once. After that wait, an
+empty state offers **Try again**, which re-reads the channel list and waits the
+same way again. The client never joins the welcome channel for them. The relay
+adds people to its welcome and `auto_join` channels itself, welcome channel
+first, and the resulting membership hints populate the sidebar live:
- A community that requires relay membership adds a member once, when they join
its roster: an invite claim that answers `joined`, or an admin's kind `9030`
diff --git a/docs/communities.md b/docs/communities.md
index 7b0da62bdd..e39d3b883a 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -190,8 +190,10 @@ there are no mentions, attachments, GIFs or cards. The message is stored as
Markdown and rendered as messages are: published Markdown opens as its source
text, as when editing a sent message, and formatting applied in the editor is
written as Markdown. `[@Name](nostr:npub…)` identity links show the person
-without notifying them, and channel or message links pasted from **Copy link**
-show as chips in the editor and open in place for members. It takes
+without notifying them; a pasted npub from **Copy npub**, bare or as a
+`nostr:` URI, becomes one, labelled with the person's name when it is known.
+Channel or message links pasted from **Copy link** show as chips in the editor,
+and as linked names in the preview, and open in place for members. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
Both are checked against the relay's rules as the admin types. Line endings are
normalized and both are trimmed before sending. A message matching the default
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 4f671c726d..09be0f9084 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -31,7 +31,7 @@ import {
} from "../../features/relay/session";
import type { RelayData } from "../../features/relay/service";
import { matchesEvent } from "../../features/relay/projection";
-import type { RelayEvent } from "../../features/relay/events";
+import type { ReadFilter, RelayEvent } from "../../features/relay/events";
import {
keypair,
metadata,
@@ -304,21 +304,25 @@ it.each(["side", "main"] as const)(
},
);
-/** A page over `events` with no saved selection and no registered panels. */
+/** A page over `events` with no saved selection and no registered panels.
+ * A read waits for the promise `hold` returns for its filters, if any. */
function workspace(
events: RelayEvent[],
viewer = keypair(),
authority = keypair(),
+ hold?: (filters: readonly ReadFilter[]) => Promise | undefined,
) {
const owner = createRelaySession(
{
viewer: viewer.pubkey,
relayAuthor: authority.pubkey,
media: () => undefined,
- query: async (filters) =>
- events.filter((event) =>
+ query: async (filters) => {
+ await hold?.(filters);
+ return events.filter((event) =>
filters.some((filter) => matchesEvent(event, filter)),
- ),
+ );
+ },
writer: {
sign: async (template) => signed(viewer, template),
publish: async () => {},
@@ -438,6 +442,63 @@ it("opens the community's welcome channel before its auto-join channel and start
expect(screen.queryByText("No messages yet.")).toBeNull();
});
+it("waits for the welcome channel's metadata before opening an auto-join channel listed first", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ let release = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ let holding = false;
+ const { owner } = workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [
+ ["t", "stream"],
+ ["auto_join"],
+ ]),
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ ],
+ viewer,
+ authority,
+ (filters) => {
+ // Hold every metadata read naming the welcome channel.
+ if (
+ !filters.some(
+ (filter) =>
+ filter.kinds?.includes(39000) && filter["#d"]?.includes("beta"),
+ )
+ )
+ return;
+ holding = true;
+ return held;
+ },
+ );
+ try {
+ await vi.waitFor(() => expect(holding).toBe(true));
+ // The auto-join channel's metadata lands first, by an exact lookup.
+ await act(() => owner.session.channels.resolve?.(["alpha"]));
+ const listed = owner.session.channels.list().channels;
+ const alpha = listed.find(({ id }) => id === "alpha");
+ expect(alpha?.autoJoin).toBe(true);
+ expect(alpha?.metadataPending).toBeFalsy();
+ expect(listed.find(({ id }) => id === "beta")?.metadataPending).toBe(true);
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ expect(
+ screen.queryByRole("textbox", { name: "Message #Alpha" }),
+ ).toBeNull();
+ } finally {
+ release();
+ }
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
+});
+
it("shows the default welcome only in the welcome channel", async () => {
const viewer = keypair(),
authority = keypair();
@@ -546,7 +607,9 @@ it("keeps opening after an empty first list, then offers a list refresh instead
expect(
screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
).toBeNull();
- expect(screen.getByRole("region", { name: "Channel history" })).toHaveFocus();
+ expect(
+ screen.getByRole("region", { name: "Channel history" }),
+ ).toHaveFocus();
await screen.findByRole("textbox", { name: "Message #Beta" });
} finally {
vi.useRealTimers();
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 0d6ad51d84..742d12564a 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -583,10 +583,13 @@ function ChannelWorkspace({
channels.find(
(channel) => channel.landing && channel.channelType === "stream",
) ??
- channels.find(
- (channel) => channel.autoJoin && channel.channelType !== "solo",
- ) ??
- channels.find((item) => item.channelType !== "solo"));
+ // A channel whose metadata is still pending can be the welcome
+ // channel, so the fallbacks wait for it rather than settle first.
+ (metadataPending && list.status !== "error"
+ ? undefined
+ : (channels.find(
+ (channel) => channel.autoJoin && channel.channelType !== "solo",
+ ) ?? channels.find((item) => item.channelType !== "solo"))));
// Exact links and saved selections must respect the same Huddle-only entry point.
const resolving =
resolvingMembership ||
diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx
index ebea1375d6..cb313df78b 100644
--- a/src/bundled/moderation/WelcomeMessageEditor.tsx
+++ b/src/bundled/moderation/WelcomeMessageEditor.tsx
@@ -30,11 +30,13 @@ import {
mentionDraft,
type MentionDraft,
} from "../../features/messages/mention-draft";
+import { profileMentionLink } from "../../features/messages/message-edit";
import { messageLinkParts } from "../../features/messages/message-link-parts";
import { profileKey } from "../../features/profiles/target";
import { WELCOME_MAX_LENGTH } from "../../features/relay/landing";
import { scanMarkdown } from "../../features/relay/message-content";
import type { RelaySession } from "../../features/relay/session";
+import { formatPublicKey } from "../../shared/identity/public-key";
import { Field } from "../../shared/design-system/ui/Field";
import { InputGroup } from "../../shared/design-system/ui/InputGroup";
import styles from "./WelcomeMessageEditor.module.css";
@@ -78,12 +80,27 @@ function linkRanges(text: string, rich: readonly SourceRange[]) {
return ranges.sort((a, b) => a.start - b.start);
}
+/**
+ * A pasted npub, bare as Copy npub writes it or as a `nostr:` URI, becomes an
+ * identity link labelled with the person's name when it is known, so it shows
+ * as a chip. Like every identity link here, it notifies no one.
+ */
+function identityPaste(text: string, names: RelaySession["names"]) {
+ const npub = text.trim().replace(/^nostr:/, "");
+ const target = `nostr:${npub}`;
+ const pubkey = profileKey(target);
+ if (!pubkey) return text;
+ const name = names?.resolve(pubkey) ?? formatPublicKey(pubkey) ?? npub;
+ return profileMentionLink(`@${name}`, target);
+}
+
/**
* The welcome message in the composer's editor, with its formatting toolbar,
* shortcuts and link dialog. The value is Markdown source, as the relay stores
* it: formatting applied here is written as the composer sends it, and
* published Markdown opens as its source and is sent back unchanged. There
- * are no recipients, so identity links only name people.
+ * are no recipients, so identity links, pasted npubs included, only name
+ * people.
*/
export function WelcomeMessageEditor({
ref,
@@ -196,6 +213,7 @@ export function WelcomeMessageEditor({
onFormatsChange={setFormats}
onEditLink={setLinkEdit}
onPasteTooLong={() => setPasteTooLong(true)}
+ pastedText={(text) => identityPaste(text, session.names)}
/>
({
@@ -73,17 +80,45 @@ const rows = [
},
];
+/** The conversation renderers the app provides, with the bundled Links
+ * renderer that draws Buzz and web links. */
+function linkExtensions(): ConversationExtensions {
+ const links: Contribution[] = [];
+ applyLinks({
+ conversation: {
+ registerLink: (renderer: LinkRenderer) =>
+ links.push({
+ ...renderer,
+ key: `buzz.links/${renderer.id}`,
+ pluginId: "buzz.links",
+ revision: "one",
+ }),
+ },
+ } as never);
+ const none: readonly never[] = [];
+ const reader = (entries: readonly T[]) => ({
+ snapshot: () => entries,
+ subscribe: () => () => {},
+ });
+ return { links: reader(links), tools: reader(none), inline: reader(none) };
+}
+
function setup({
info = { policy: null, landingChannels: true, landingChannel: lobby },
landing = { landing: true, welcome: "Hi there", name: "Lobby" },
joined = [],
directory = rows,
+ extensions,
+ names,
}: {
info?: CommunityInfo;
landing?: ChannelLanding | undefined;
/** The viewer's own channels, beside the directory. */
joined?: ChannelSummary[];
directory?: typeof rows;
+ extensions?: ConversationExtensions;
+ /** Known names by public key. */
+ names?: Readonly>;
} = {}) {
const readLanding = vi.fn(async () => landing);
const setLanding = vi.fn(
@@ -96,6 +131,14 @@ function setup({
viewer: owner,
media: () => undefined,
channelLifecycle: { available: true, readLanding, setLanding, run },
+ ...(names && {
+ names: {
+ resolve: (pubkey: string, fallback?: string) =>
+ names[pubkey] ?? fallback,
+ snapshot: () => 0,
+ subscribe: () => () => {},
+ },
+ }),
channels: {
list: () => list,
ensureList: () => () => {},
@@ -145,6 +188,7 @@ function setup({
relay={relay}
communityName="Primary"
active={() => true}
+ extensions={extensions}
/>,
{ wrapper: ToastProvider },
);
@@ -211,7 +255,7 @@ it("opens on a custom title and renders the message as Markdown in the preview",
expect(preview.querySelector("strong")).toHaveTextContent("hi");
expect(
screen.getByText(
- /Format with the toolbar or Markdown\. To link a channel, use Copy link/,
+ /Format with the toolbar or Markdown\. To link a channel or person, paste a copied channel link or npub\./,
),
).toBeVisible();
});
@@ -252,6 +296,91 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
);
});
+it("draws channel links in the preview as the welcome card does", async () => {
+ // A channel's Copy link writes the bare link; the joined list names it.
+ setup({
+ landing: {
+ landing: true,
+ welcome: `Say hi in buzz://channel/${square}`,
+ name: "Lobby",
+ },
+ joined: [{ id: square, name: "Town square", channelType: "stream" }],
+ extensions: linkExtensions(),
+ });
+ const preview = await screen.findByRole("region", {
+ name: "Welcome to Primary",
+ });
+ await waitFor(() =>
+ expect(preview.querySelector("[data-link-kind='channel']")).not.toBeNull(),
+ );
+ const link = preview.querySelector("[data-link-kind='channel']");
+ // The channel icon supplies the hash, as in the timeline.
+ expect(link).toHaveTextContent(/^Town square$/);
+ expect(link?.querySelector("svg")).not.toBeNull();
+ expect(link?.closest("[data-link-renderer]")).toHaveAttribute(
+ "data-link-renderer",
+ "buzz.links/links",
+ );
+ // The preview stays inert: no link to follow.
+ expect(within(preview).queryByRole("link")).toBeNull();
+});
+
+/** Paste `text` as plain text at the start of an emptied message field. */
+function pasteIntoMessage(text: string) {
+ const input = messageField();
+ fireEvent.change(input, { target: { value: "" } });
+ act(() => {
+ input.focus();
+ document.dispatchEvent(new Event("selectionchange"));
+ fireEvent.paste(input, {
+ clipboardData: {
+ items: [],
+ getData: (type: string) => (type === "text/plain" ? text : ""),
+ },
+ });
+ });
+}
+
+it("links a pasted npub to the person, named as their profile names them", async () => {
+ const alice = "b".repeat(64);
+ const npub = npubEncode(alice);
+ const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ // Copy npub writes the bare npub.
+ pasteIntoMessage(npub);
+ const preview = screen.getByRole("region", { name: "Welcome to Primary" });
+ await waitFor(() =>
+ expect(preview.querySelector("[data-profile-target]")).toHaveTextContent(
+ "Alice",
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`[@Alice](nostr:${npub})`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("labels a pasted nostr: npub with a short key when the name is unknown", async () => {
+ const npub = npubEncode("c".repeat(64));
+ const { setLanding, user } = setup({ names: {} });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ pasteIntoMessage(`nostr:${npub}`);
+ await user.click(await screen.findByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`[@npub…${npub.slice(-3)}](nostr:${npub})`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
const publishedMarkdown = [
"**Welcome** to _Primary_",
"",
@@ -715,12 +844,20 @@ it("browses the first 50 channels by name in both pickers and finds the rest by
const names = () =>
screen.getAllByRole("option").map((option) => option.textContent);
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- // The welcome channel picker: 63 eligible channels, the first 50 shown.
+ // The welcome channel picker: 63 eligible channels, the first 50 shown,
+ // also while the field still shows the selected channel's name.
await user.click(screen.getByRole("button", { name: "Browse channels" }));
await waitFor(() => expect(names()).toHaveLength(50));
+ expect(channel()).toHaveValue("Lobby");
expect(names()[0]).toBe("Channel 01");
expect(names()[49]).toBe("Channel 50");
expect(screen.getByText(hint)).toBeVisible();
+ // Reopened by clicking the field, the selection still browses 50.
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(screen.queryAllByRole("option")).toHaveLength(0));
+ await user.click(channel());
+ await waitFor(() => expect(names()).toHaveLength(50));
+ expect(channel()).toHaveValue("Lobby");
// A search covers every channel, including those past the first 50.
await user.clear(channel());
await user.type(channel(), "town");
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 31ff304f8e..60d9ffb8a7 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -17,6 +17,7 @@ import {
type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
import type { WelcomeCommand } from "../../features/relay/channel-lifecycle-protocol";
+import type { ConversationExtensions } from "../../features/conversation/contracts";
import type { ChannelSummary } from "../../features/relay/contracts";
import { communityFromScope } from "../../features/relay/gifs";
import {
@@ -105,10 +106,13 @@ export function WelcomeSettings({
relay,
communityName,
active,
+ extensions,
}: {
relay: RelayData;
communityName?: string | undefined;
active(): boolean;
+ /** The conversation renderers, so the preview draws links as the card does. */
+ extensions?: ConversationExtensions | undefined;
}) {
const connection = useRelayConnection(relay);
const community =
@@ -130,6 +134,7 @@ export function WelcomeSettings({
community={community}
communityName={communityName}
active={active}
+ extensions={extensions}
/>
) : (
Choose a connected community to set its welcome.
@@ -242,12 +247,14 @@ function WelcomeEditor({
community,
communityName,
active,
+ extensions,
}: {
session: RelaySession;
scope: string;
community: string;
communityName?: string | undefined;
active(): boolean;
+ extensions: ConversationExtensions | undefined;
}) {
const lifecycle = session.channelLifecycle;
const defaultTitle = defaultWelcomeTitle(communityName);
@@ -492,6 +499,9 @@ function WelcomeEditor({
items={options}
filteredItems={matches}
+ // Until something is typed, a field showing the chosen name
+ // lists `items` instead of `filteredItems`; cap that browse too.
+ limit={BROWSE_LIMIT}
value={selected}
onValueChange={(option) => {
if (option) edit({ channelId: option.id });
@@ -565,8 +575,8 @@ function WelcomeEditor({
label="Message"
description={
<>
- Format with the toolbar or Markdown. To link a channel, use
- Copy link in its menu and paste the link here.{" "}
+ Format with the toolbar or Markdown. To link a channel or
+ person, paste a copied channel link or npub.{" "}
{[...normalized].length.toLocaleString("en-US")} of {MAX}{" "}
characters
@@ -672,6 +682,7 @@ function WelcomeEditor({
session={session}
scope={scope}
channelId={channelId ?? ""}
+ extensions={extensions}
onOpenLink={() => false}
interactive={false}
/>
diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx
index 8abfd19953..959388ccd7 100644
--- a/src/bundled/moderation/index.tsx
+++ b/src/bundled/moderation/index.tsx
@@ -3,7 +3,13 @@ import { CommunityAdmin } from "./CommunityAdmin";
import { canManageMembership } from "./membership";
import { WelcomeSettings } from "./WelcomeSettings";
-export const inject = ["relay", "settingsCards", "adminAccess", "navigation"];
+export const inject = [
+ "relay",
+ "settingsCards",
+ "adminAccess",
+ "navigation",
+ "conversation",
+];
export const apply: PluginModule["apply"] = (ctx) => {
const relay = ctx.relay;
const membership = ctx.adminAccess.membership;
@@ -78,6 +84,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
relay={relay}
communityName={community?.name}
active={active}
+ extensions={ctx.conversation}
/>
),
});
diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts
index 80378e131c..6012160fcb 100644
--- a/src/bundled/moderation/registration.test.ts
+++ b/src/bundled/moderation/registration.test.ts
@@ -64,6 +64,8 @@ it("registers the Membership card under the key the community rail opens", async
});
const data = relay();
root.provide("relay", data);
+ // The Welcome card's preview renders through the conversation renderers.
+ root.provide("conversation", {} as never);
const access = createAdminAccess(
data,
{
@@ -123,6 +125,7 @@ it.each([
});
const data = relay(role);
root.provide("relay", data);
+ root.provide("conversation", {} as never);
const access = createAdminAccess(
data,
{
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
index 4bdde1b694..ba9d6a3673 100644
--- a/src/features/communities/WelcomeCard.test.tsx
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -70,7 +70,9 @@ it("opens an identity link's profile from the timeline without notifying", async
canOpenLink: () => true,
}),
);
- await userEvent.click(screen.getByRole("button", { name: "View Ana profile" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: "View Ana profile" }),
+ );
expect(onOpenLink).toHaveBeenCalledWith(target);
});
@@ -112,7 +114,9 @@ it("titles the default with the community the viewer saved, else a custom title"
);
expect(screen.getByRole("region", { name: "Hello, newcomer" })).toBeVisible();
rerender(
- Body ,
+
+ Body
+ ,
);
expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
});
diff --git a/src/features/messages/EditableInput.tsx b/src/features/messages/EditableInput.tsx
index b85c76342d..361b74a76d 100644
--- a/src/features/messages/EditableInput.tsx
+++ b/src/features/messages/EditableInput.tsx
@@ -389,6 +389,9 @@ export type EditableInputProps = Omit<
/** Vet a pasted identity link as a notification recipient: the recipient under
* its current name, or null to keep the display-only chip. */
acceptRecipient?(pubkey: string): MentionRecipient | null;
+ /** The source to insert for pasted text. Raw pastes and code blocks keep
+ * the text as copied. */
+ pastedText?(text: string): string;
};
/** ProseMirror owns native editing, composition, selection and a single history.
@@ -406,6 +409,7 @@ export function EditableInput({
onEditLink,
onPasteTooLong,
acceptRecipient,
+ pastedText,
...events
}: EditableInputProps & {
draft: MentionDraft;
@@ -423,6 +427,7 @@ export function EditableInput({
onEditLink,
onPasteTooLong,
acceptRecipient,
+ pastedText,
disabled,
placeholder,
autocorrect,
@@ -436,6 +441,7 @@ export function EditableInput({
onEditLink,
onPasteTooLong,
acceptRecipient,
+ pastedText,
disabled,
placeholder,
autocorrect,
@@ -2182,6 +2188,8 @@ export function EditableInput({
externalLinkMarkdown(html, hasPlain ? plain : undefined))) ||
plain ||
"";
+ if (!code && current.current.pastedText)
+ text = current.current.pastedText(text);
if (!text) return true;
// A bare URL pasted over selected text links the selection, as other
// editors do. editLink() owns the guards (code, resources, unsafe
From a1859894cf204541bbde0113fc2910745a7f50d7 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 18:57:15 +1100
Subject: [PATCH 09/28] fix(communities): let a later welcome channel replace a
provisional default; keep npubs pasted into code or links
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-up for the welcome channel and the welcome editor.
A new member's membership hints can confirm their starter channel on
one read and their welcome channel on a later one. The metadata guard
only waits for a welcome row already listed, so the starter channel
opened and stayed. A starter or first channel chosen while no welcome
channel is listed is now provisional: a welcome channel that arrives
within ENROLLMENT_WAIT_MS of the list first loading replaces it,
reopening in place (replace, with the visit's focus). Pointer or key
input in the workspace, or any other visit, settles the choice, and
after the window nothing flips. The metadata guard stays, because the
NIP-11 landing_channel is not available to Messages: RelaySession
(FOUNDATION) does not project it. docs/channels.md describes the rule.
EditableInput's opt-in pastedText hook now receives where the paste
lands, as draft-text offsets with the source. It is skipped when the
caret's marks include code or link. The welcome editor leaves an npub
as pasted when it lands inside a Markdown code span or link, or right
after a `[label](` still being typed. Typing `[@Alice](`, pasting
`nostr:npub1…` and typing `)` now gives one valid link, and a bare npub
in prose still becomes `[@Name](nostr:npub1…)`. WelcomeMessageEditor
is the only caller.
New tests were written but not run. ChannelsPage covers a welcome
channel confirmed after the starter channel, a click in the starter
channel first, and the closed window. WelcomeSettings covers prose,
a link destination being typed, backticks and toolbar code. tsc
--noEmit and biome check pass.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 7 +-
docs/communities.md | 2 +
.../channels/ChannelsPage.tabs.test.tsx | 71 +++++++++++
src/bundled/channels/ChannelsPage.tsx | 119 +++++++++++++-----
.../moderation/WelcomeMessageEditor.tsx | 36 +++++-
.../moderation/WelcomeSettings.test.tsx | 109 ++++++++++++++--
src/features/messages/EditableInput.tsx | 27 +++-
7 files changed, 320 insertions(+), 51 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 7a4b4ef122..b825a5ab27 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1307,7 +1307,12 @@ channel for new members), then the first non-Solo channel; an explicit empty
route bypasses all three. The welcome channel is the stream channel whose
relay-signed `39000` carries `landing`. Until every listed channel's metadata
has arrived, any of them could be the welcome channel, so the starter and
-first-channel fallbacks wait. The welcome channel's timeline starts with a
+first-channel fallbacks wait. The relay's membership hints can still add a new
+member to their starter channel before their welcome channel, so a fallback
+opened while no welcome channel is listed is provisional: a welcome channel that
+arrives within ten seconds of the channel list first loading replaces it, unless
+the viewer has clicked or typed in Messages or opened anything else meanwhile.
+The welcome channel's timeline starts with a
welcome card for every viewer, even with no messages: the community name and its
custom message, or the default one. The card is not a message, so it has no
unread state, actions or dismissal. While the channel list or its metadata is
diff --git a/docs/communities.md b/docs/communities.md
index e39d3b883a..d66b39606e 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -192,6 +192,8 @@ text, as when editing a sent message, and formatting applied in the editor is
written as Markdown. `[@Name](nostr:npub…)` identity links show the person
without notifying them; a pasted npub from **Copy npub**, bare or as a
`nostr:` URI, becomes one, labelled with the person's name when it is known.
+Pasted into Markdown code or a link, including a link destination being typed
+after `[@Name](`, it stays as pasted.
Channel or message links pasted from **Copy link** show as chips in the editor,
and as linked names in the preview, and open in place for members. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 09be0f9084..91ec5cf787 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -659,6 +659,77 @@ it("selects a default channel the relay adds after the page opened", async () =>
await screen.findByRole("textbox", { name: "Message #Beta" });
});
+/** A new member's empty first list, then a membership hint confirming their
+ * starter channel on its own read, as the relay's hints can arrive. */
+async function starterConfirmedFirst() {
+ const viewer = keypair(),
+ authority = keypair();
+ const events: RelayEvent[] = [];
+ const { owner } = workspace(events, viewer, authority);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ events.push(
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [
+ ["t", "stream"],
+ ["auto_join"],
+ ]),
+ );
+ await act(() => owner.session.channels.resolve?.(["alpha"]));
+ const alpha = await screen.findByRole("textbox", { name: "Message #Alpha" });
+ /** The welcome channel's hint, confirmed on a later read. */
+ const confirmWelcome = async () => {
+ events.push(
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ );
+ await act(() => owner.session.channels.resolve?.(["beta"]));
+ await vi.waitFor(() =>
+ expect(
+ owner.session.channels.list().channels.find(({ id }) => id === "beta")
+ ?.landing,
+ ).toBe(true),
+ );
+ };
+ return { alpha, confirmWelcome };
+}
+
+it("opens a welcome channel confirmed after the starter channel it opened first", async () => {
+ const { confirmWelcome } = await starterConfirmedFirst();
+ await confirmWelcome();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
+});
+
+it("keeps the starter channel once the reader clicks in it, though the welcome channel follows", async () => {
+ const user = userEvent.setup();
+ const { alpha, confirmWelcome } = await starterConfirmedFirst();
+ await user.click(alpha);
+ await confirmWelcome();
+ expect(screen.getByRole("textbox", { name: "Message #Alpha" })).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull();
+});
+
+it("keeps the starter channel when the welcome channel follows after the opening window", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ const { confirmWelcome } = await starterConfirmedFirst();
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+ await confirmWelcome();
+ expect(
+ screen.getByRole("textbox", { name: "Message #Alpha" }),
+ ).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
it("keeps the channel being read when a default channel arrives later", async () => {
const viewer = keypair(),
authority = keypair();
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 742d12564a..43c09e7943 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -54,7 +54,10 @@ import { ChannelJoinNotice } from "../../features/messages/ChannelJoinNotice";
import { ChannelLifecycleActions } from "./ChannelLifecycleActions";
import { lifecycleNoun } from "./ChannelLifecycleDialog";
import type { PageNavigation } from "../../features/navigation/service";
-import type { Navigation } from "../../features/navigation/controller";
+import type {
+ Navigation,
+ NavigationOpenOptions,
+} from "../../features/navigation/controller";
import { targetKey, type OpenTarget } from "../../features/navigation/targets";
import {
buzzLinkTarget,
@@ -437,19 +440,22 @@ function ChannelWorkspace({
return () => clearPreparingDm?.();
}, [composingMessage, clearPreparingDm]);
const navigate = useCallback(
- (id: string) => {
+ (id: string, options?: NavigationOpenOptions) => {
setSelected(id);
writeView(scope, "selected-channel", id);
if (navigator && viewer) {
- void navigator.open({
- version: 1,
- kind: "conversation",
- channelId: id,
- scope: {
- viewer,
- communityOrigin: scope.slice(0, -(viewer.length + 1)),
+ void navigator.open(
+ {
+ version: 1,
+ kind: "conversation",
+ channelId: id,
+ scope: {
+ viewer,
+ communityOrigin: scope.slice(0, -(viewer.length + 1)),
+ },
},
- });
+ options,
+ );
}
},
[navigator, viewer, scope],
@@ -562,6 +568,28 @@ function ChannelWorkspace({
!!draft ||
(navigation?.target.kind === "page" &&
navigation.target.route?.params === "empty");
+ const kept =
+ (!navigation && !cached && visiblePreview.current?.queries === queries
+ ? queries.channels.get?.(visiblePreview.current.channelId)
+ : undefined) ??
+ channels.find((channel) => channel.id === selected) ??
+ // A computed choice stays put while its channel is listed, so a channel
+ // flagged or added later never swaps the one being read; only a welcome
+ // channel can replace a provisional fallback (see `provisional`).
+ channels.find((channel) => channel.id === chosen.current);
+ // A new member lands in the community's welcome channel, else in its
+ // default channel when it has one.
+ const welcomeChannel = channels.find(
+ (channel) => channel.landing && channel.channelType === "stream",
+ );
+ // A channel whose metadata is still pending can be the welcome channel, so
+ // the starter and first-channel fallbacks wait for it rather than settle first.
+ const fallback =
+ kept || welcomeChannel || (metadataPending && list.status !== "error")
+ ? undefined
+ : (channels.find(
+ (channel) => channel.autoJoin && channel.channelType !== "solo",
+ ) ?? channels.find((item) => item.channelType !== "solo"));
const requestedDestination = emptyDestination
? undefined
: requestedChannel
@@ -571,25 +599,7 @@ function ChannelWorkspace({
(previewAvailable
? queries.channels.get?.(requestedChannel)
: undefined))
- : ((!navigation && !cached && visiblePreview.current?.queries === queries
- ? queries.channels.get?.(visiblePreview.current.channelId)
- : undefined) ??
- channels.find((channel) => channel.id === selected) ??
- // A computed choice stays put while its channel is listed, so a channel
- // flagged or added later never swaps the one being read.
- channels.find((channel) => channel.id === chosen.current) ??
- // A new member lands in the community's welcome channel, else in its
- // default channel when it has one.
- channels.find(
- (channel) => channel.landing && channel.channelType === "stream",
- ) ??
- // A channel whose metadata is still pending can be the welcome
- // channel, so the fallbacks wait for it rather than settle first.
- (metadataPending && list.status !== "error"
- ? undefined
- : (channels.find(
- (channel) => channel.autoJoin && channel.channelType !== "solo",
- ) ?? channels.find((item) => item.channelType !== "solo"))));
+ : (kept ?? welcomeChannel ?? fallback);
// Exact links and saved selections must respect the same Huddle-only entry point.
const resolving =
resolvingMembership ||
@@ -627,14 +637,59 @@ function ChannelWorkspace({
list.status === "loading" ||
metadataPending ||
(emptyList && !noChannels));
+ // The relay's membership hints can confirm a new member's starter channel
+ // before their welcome channel, so a fallback chosen with no welcome channel
+ // listed is provisional. Until the reader acts in this workspace or moves to
+ // another visit, a welcome channel arriving within ENROLLMENT_WAIT_MS of the
+ // list first loading replaces it.
+ const listed = useRef(undefined);
+ const provisional = useRef<{
+ channelId: string;
+ entryId: string | undefined;
+ }>(undefined);
useLayoutEffect(() => {
+ if (list.status === "ready") listed.current ??= Date.now();
+ if (
+ provisional.current &&
+ provisional.current.entryId !== navigation?.entryId
+ )
+ provisional.current = undefined;
// An open draft resolves nothing; keep the choice for when it closes.
- if (requestedDestination) chosen.current = requestedDestination.id;
+ if (requestedDestination) {
+ if (!requestedChannel && requestedDestination === fallback)
+ provisional.current = {
+ channelId: requestedDestination.id,
+ entryId: navigation?.entryId,
+ };
+ chosen.current = requestedDestination.id;
+ }
visiblePreview.current =
current?.readOnly && !current.cached && !cached
? { channelId: current.id, queries }
: undefined;
});
+ const settleDefault = () => {
+ provisional.current = undefined;
+ };
+ const welcomeId = welcomeChannel?.id;
+ const currentId = current?.id;
+ const visitFocus = navigation?.focus;
+ useEffect(() => {
+ const choice = provisional.current;
+ if (!choice || !welcomeId) return;
+ provisional.current = undefined;
+ if (
+ currentId === choice.channelId &&
+ welcomeId !== choice.channelId &&
+ listed.current !== undefined &&
+ Date.now() - listed.current < ENROLLMENT_WAIT_MS
+ )
+ // The same opening, corrected: no return path to the fallback.
+ navigate(welcomeId, {
+ replace: true,
+ ...(visitFocus && { focus: visitFocus }),
+ });
+ }, [welcomeId, currentId, visitFocus, navigate]);
// Sidebar routing can update the same mounted page. Keep its saved default
// aligned with the resolved conversation, not only page-local clicks.
useEffect(() => {
@@ -736,7 +791,6 @@ function ChannelWorkspace({
navigation.resolve(target);
}
}, [cached, current, navigation]);
- const currentId = current?.id;
useEffect(() => {
setMembersChannel((id) => (id === currentId ? id : undefined));
}, [currentId]);
@@ -2237,6 +2291,9 @@ function ChannelWorkspace({
ref={split.ref}
style={split.style}
className={`${styles.board} ${panelStyles.splitMotion}`}
+ // The reader acting here keeps a provisional default; see `provisional`.
+ onPointerDownCapture={settleDefault}
+ onKeyDownCapture={settleDefault}
>
{current && membersOpen && (
a.start - b.start);
}
+/** Where a paste lands: over `start`–`end` of the draft text `source`. */
+type PasteAt = SourceRange & { source: string };
+
+/** Whether `text`, pasted as is, lands inside Markdown code, a link, or a link
+ * destination still being typed. */
+function pastedIntoMarkup(text: string, { source, start, end }: PasteAt) {
+ const before = source.slice(0, start);
+ // `[label](` typed so far, perhaps with part of the destination.
+ if (/\]\([^\s()<>]*$/.test(before)) return true;
+ const after = before + text + source.slice(end);
+ const pasted = { start, end: start + text.length };
+ const spans = [
+ ...markdownRanges(after).literal,
+ ...scanMarkdown(after).links.map(({ position }) => ({
+ start: position?.start.offset ?? 0,
+ end: position?.end.offset ?? 0,
+ })),
+ ];
+ return spans.some(
+ (span) => pasted.start > span.start && pasted.end < span.end,
+ );
+}
+
/**
* A pasted npub, bare as Copy npub writes it or as a `nostr:` URI, becomes an
* identity link labelled with the person's name when it is known, so it shows
- * as a chip. Like every identity link here, it notifies no one.
+ * as a chip. Like every identity link here, it notifies no one. Pasted into
+ * Markdown code or a link, it stays as pasted.
*/
-function identityPaste(text: string, names: RelaySession["names"]) {
+function identityPaste(
+ text: string,
+ at: PasteAt,
+ names: RelaySession["names"],
+) {
const npub = text.trim().replace(/^nostr:/, "");
const target = `nostr:${npub}`;
const pubkey = profileKey(target);
- if (!pubkey) return text;
+ if (!pubkey || pastedIntoMarkup(text, at)) return text;
const name = names?.resolve(pubkey) ?? formatPublicKey(pubkey) ?? npub;
return profileMentionLink(`@${name}`, target);
}
@@ -213,7 +241,7 @@ export function WelcomeMessageEditor({
onFormatsChange={setFormats}
onEditLink={setLinkEdit}
onPasteTooLong={() => setPasteTooLong(true)}
- pastedText={(text) => identityPaste(text, session.names)}
+ pastedText={(text, at) => identityPaste(text, at, session.names)}
/>
{
expect(within(preview).queryByRole("link")).toBeNull();
});
-/** Paste `text` as plain text at the start of an emptied message field. */
-function pasteIntoMessage(text: string) {
- const input = messageField();
- fireEvent.change(input, { target: { value: "" } });
+/** A plain-text clipboard holding `text`. */
+const plainClipboard = (text: string) => ({
+ clipboardData: {
+ items: [],
+ getData: (type: string) => (type === "text/plain" ? text : ""),
+ },
+});
+
+/** Paste `text` as plain text into a message field holding only `before` and
+ * `after`, with the caret between them. */
+function pasteIntoMessage(text: string, before = "", after = "") {
+ const input = messageField() as ComposerInputElement;
+ fireEvent.change(input, { target: { value: before + after } });
act(() => {
input.focus();
+ input.setSelectionRange(before.length, before.length);
document.dispatchEvent(new Event("selectionchange"));
- fireEvent.paste(input, {
- clipboardData: {
- items: [],
- getData: (type: string) => (type === "text/plain" ? text : ""),
- },
- });
+ fireEvent.paste(input, plainClipboard(text));
});
}
@@ -381,6 +386,90 @@ it("labels a pasted nostr: npub with a short key when the name is unknown", asyn
);
});
+it("links an npub pasted into prose", async () => {
+ const alice = "b".repeat(64);
+ const npub = npubEncode(alice);
+ const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ pasteIntoMessage(npub, "Ask ", " anything");
+ await user.click(await screen.findByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`Ask [@Alice](nostr:${npub}) anything`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("keeps an npub pasted as a link destination being typed, so the link completes", async () => {
+ const alice = "b".repeat(64);
+ const npub = npubEncode(alice);
+ const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ pasteIntoMessage(`nostr:${npub}`, "Ask [@Alice](");
+ await user.keyboard(")");
+ expect(messageField()).toHaveValue(`Ask [@Alice](nostr:${npub})`);
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`Ask [@Alice](nostr:${npub})`),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("keeps an npub pasted between backticks as literal code", async () => {
+ const alice = "b".repeat(64);
+ const npub = npubEncode(alice);
+ const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ pasteIntoMessage(npub, "Key: `", "`");
+ expect(messageField()).toHaveValue(`Key: \`${npub}\``);
+ await user.click(await screen.findByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`Key: \`${npub}\``),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
+it("keeps an npub pasted into toolbar code as pasted", async () => {
+ const alice = "b".repeat(64);
+ const npub = npubEncode(alice);
+ const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ const input = messageField();
+ fireEvent.change(input, { target: { value: "" } });
+ act(() => {
+ input.focus();
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ // A pointer click leaves focus and the caret, with Code on, in the editor.
+ await user.click(screen.getByRole("button", { name: "Toggle formatting" }));
+ await user.click(screen.getByRole("button", { name: "Code" }));
+ expect(input).toHaveFocus();
+ act(() => {
+ fireEvent.paste(input, plainClipboard(npub));
+ });
+ expect(input.querySelector("code")).toHaveTextContent(npub);
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome(`\`${npub}\``),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
const publishedMarkdown = [
"**Welcome** to _Primary_",
"",
diff --git a/src/features/messages/EditableInput.tsx b/src/features/messages/EditableInput.tsx
index 361b74a76d..9b475fff83 100644
--- a/src/features/messages/EditableInput.tsx
+++ b/src/features/messages/EditableInput.tsx
@@ -389,9 +389,12 @@ export type EditableInputProps = Omit<
/** Vet a pasted identity link as a notification recipient: the recipient under
* its current name, or null to keep the display-only chip. */
acceptRecipient?(pubkey: string): MentionRecipient | null;
- /** The source to insert for pasted text. Raw pastes and code blocks keep
- * the text as copied. */
- pastedText?(text: string): string;
+ /** The source to insert for pasted text that replaces `start`–`end` of the
+ * draft text `source`. Raw pastes, code and links keep the text as copied. */
+ pastedText?(
+ text: string,
+ at: { source: string; start: number; end: number },
+ ): string;
};
/** ProseMirror owns native editing, composition, selection and a single history.
@@ -2188,8 +2191,22 @@ export function EditableInput({
externalLinkMarkdown(html, hasPlain ? plain : undefined))) ||
plain ||
"";
- if (!code && current.current.pastedText)
- text = current.current.pastedText(text);
+ const marks =
+ editor.state.storedMarks ?? editor.state.selection.$from.marks();
+ if (
+ !code &&
+ current.current.pastedText &&
+ !marks.some(
+ ({ type }) =>
+ type === composerSchema.marks.code ||
+ type === composerSchema.marks.link,
+ )
+ )
+ text = current.current.pastedText(text, {
+ source: source.draft.text,
+ start,
+ end,
+ });
if (!text) return true;
// A bare URL pasted over selected text links the selection, as other
// editors do. editLink() owns the guards (code, resources, unsafe
From cde8a1a5c66a7684fada988f3bc323d530f15f9a Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 19:18:43 +1100
Subject: [PATCH 10/28] fix(communities): switch to the welcome channel only on
its own visit, and settle on input anywhere
Review follow-up for the provisional welcome switch and the npub paste.
The welcome channel now replaces a provisional starter or first channel
only while the visit that chose it is still the one shown, compared by
history entry as setMembersOpen does. Settings opened over Messages, or a
channel opened from the sidebar in the meantime, is never replaced.
Settings used to close, or App's departure guard asked to discard edits,
with no user action. An unrouted workspace, which has no visit to match,
skips the switch while Settings is shown.
While a choice is provisional, capture-phase pointerdown and keydown
listeners on document settle it. Tabbing through the sidebar or typing in
a field outside Messages now counts as acting, not only input inside the
board. The listeners are removed when the choice settles or the page
unmounts. If focus is somewhere other than the body and outside the
workspace when the switch happens, the replace passes focus "preserve",
so the welcome composer does not take it.
A pasted npub becomes an identity link only when the paste starts at the
start of the draft or after whitespace, and ends at its end or before
whitespace. A closed code span or link around it still keeps it as
pasted. This covers an unclosed backtick and a bare URL being completed
(`https://primal.net/p/` + paste). It replaces the `[label](` regex:
typing `[@Alice](`, pasting and typing `)` still gives one valid link.
Tests, written but not run: confirmWelcome now ends with an act flush.
The positive test asserts the history replace right after it, which
proves the barrier covers the switch and that no Back entry leads to
the starter channel. Negative tests also assert the history entry is
untouched. New cases: a key press outside Messages, Settings over a
routed and an unrouted starter channel, focus outside Messages kept
through the switch, and an npub pasted inside a code span, after an
unclosed backtick, in a link label and completing a bare URL. tsc
--noEmit and biome check pass.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 4 +-
docs/communities.md | 6 +-
.../channels/ChannelsPage.tabs.test.tsx | 113 ++++++++++++++----
src/bundled/channels/ChannelsPage.tsx | 71 +++++++----
.../moderation/WelcomeMessageEditor.tsx | 20 ++--
.../moderation/WelcomeSettings.test.tsx | 13 +-
6 files changed, 169 insertions(+), 58 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index b825a5ab27..e03e654e9f 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1311,7 +1311,9 @@ first-channel fallbacks wait. The relay's membership hints can still add a new
member to their starter channel before their welcome channel, so a fallback
opened while no welcome channel is listed is provisional: a welcome channel that
arrives within ten seconds of the channel list first loading replaces it, unless
-the viewer has clicked or typed in Messages or opened anything else meanwhile.
+the viewer has clicked or typed anywhere in the window or opened anything else,
+Settings included, meanwhile. Focus the viewer has moved outside Messages stays
+where it is.
The welcome channel's timeline starts with a
welcome card for every viewer, even with no messages: the community name and its
custom message, or the default one. The card is not a message, so it has no
diff --git a/docs/communities.md b/docs/communities.md
index d66b39606e..8680235a07 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -192,8 +192,10 @@ text, as when editing a sent message, and formatting applied in the editor is
written as Markdown. `[@Name](nostr:npub…)` identity links show the person
without notifying them; a pasted npub from **Copy npub**, bare or as a
`nostr:` URI, becomes one, labelled with the person's name when it is known.
-Pasted into Markdown code or a link, including a link destination being typed
-after `[@Name](`, it stays as pasted.
+That happens only when it is pasted as a word of its own, with whitespace or
+the start or end of the message on each side, outside Markdown code and links.
+Anywhere else, such as after `[@Name](`, an unclosed backtick or a URL being
+typed, it stays as pasted.
Channel or message links pasted from **Copy link** show as chips in the editor,
and as linked names in the preview, and open in place for members. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 91ec5cf787..e3ba5af6ff 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -369,11 +369,11 @@ function workspace(
);
const { rerender } = render(page());
/** Re-route the mounted page; `undefined` returns to the unrouted workspace. */
- const visit = (target?: OpenTarget) =>
+ const visit = (target?: OpenTarget, entryId = "visit") =>
rerender(
page(
target && {
- entryId: "visit",
+ entryId,
target,
signal: new AbortController().signal,
position: () => undefined,
@@ -387,7 +387,14 @@ function workspace(
},
),
);
- return { owner, viewer, authority, scope: snapshot.scope, visit };
+ return {
+ owner,
+ viewer,
+ authority,
+ scope: snapshot.scope,
+ navigator,
+ visit,
+ };
}
it("opens the community's auto-join channel first when nothing is saved", async () => {
@@ -660,12 +667,23 @@ it("selects a default channel the relay adds after the page opened", async () =>
});
/** A new member's empty first list, then a membership hint confirming their
- * starter channel on its own read, as the relay's hints can arrive. */
-async function starterConfirmedFirst() {
+ * starter channel on its own read, as the relay's hints can arrive. Routed,
+ * the page shows the navigator's current visit, as the host routes Messages. */
+async function starterConfirmedFirst(routed = false) {
const viewer = keypair(),
authority = keypair();
const events: RelayEvent[] = [];
- const { owner } = workspace(events, viewer, authority);
+ const { owner, navigator, visit } = workspace(events, viewer, authority);
+ if (routed)
+ visit(
+ {
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.channels",
+ pageId: "channels",
+ },
+ navigator.snapshot().entry.id,
+ );
await vi.waitFor(() =>
expect(owner.session.channels.list().status).toBe("ready"),
);
@@ -678,7 +696,9 @@ async function starterConfirmedFirst() {
);
await act(() => owner.session.channels.resolve?.(["alpha"]));
const alpha = await screen.findByRole("textbox", { name: "Message #Alpha" });
- /** The welcome channel's hint, confirmed on a later read. */
+ /** The welcome channel's hint, confirmed on a later read. Returns once the
+ * page has rendered the confirmed list and run its effects, so the switch
+ * has happened or never will; the first test below proves it. */
const confirmWelcome = async () => {
events.push(
roster(authority, "beta", [viewer.pubkey]),
@@ -694,37 +714,90 @@ async function starterConfirmedFirst() {
?.landing,
).toBe(true),
);
+ await act(async () => {});
};
- return { alpha, confirmWelcome };
+ return { alpha, navigator, confirmWelcome };
+}
+
+/** The starter channel kept: shown, and its history entry untouched. */
+function expectStarterKept(navigator: Navigation, shown = "home") {
+ expect(navigator.snapshot().entry.target.kind).toBe(shown);
+ expect(screen.getByRole("textbox", { name: "Message #Alpha" })).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull();
}
it("opens a welcome channel confirmed after the starter channel it opened first", async () => {
- const { confirmWelcome } = await starterConfirmedFirst();
+ const { navigator, confirmWelcome } = await starterConfirmedFirst();
await confirmWelcome();
+ // Asserted at once: the barrier the tests below rely on covers the switch.
+ // It replaces the opening, leaving no way back to the starter channel.
+ expect(navigator.snapshot()).toMatchObject({
+ canGoBack: false,
+ entry: { target: { kind: "conversation", channelId: "beta" } },
+ });
await screen.findByRole("textbox", { name: "Message #Beta" });
expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
});
-it("keeps the starter channel once the reader clicks in it, though the welcome channel follows", async () => {
- const user = userEvent.setup();
- const { alpha, confirmWelcome } = await starterConfirmedFirst();
- await user.click(alpha);
+it.each([
+ [
+ "clicks in it",
+ async (alpha: HTMLElement) => {
+ await userEvent.setup().click(alpha);
+ },
+ ],
+ [
+ "presses a key outside Messages",
+ async () => {
+ render(Sidebar );
+ fireEvent.keyDown(screen.getByRole("button", { name: "Sidebar" }), {
+ key: "Tab",
+ });
+ },
+ ],
+])(
+ "keeps the starter channel once the reader %s, though the welcome channel follows",
+ async (_, input) => {
+ const { alpha, navigator, confirmWelcome } = await starterConfirmedFirst();
+ await input(alpha);
+ await confirmWelcome();
+ expectStarterKept(navigator);
+ },
+);
+
+it.each([
+ ["a routed", true],
+ ["an unrouted", false],
+])(
+ "leaves Settings open over %s starter channel when the welcome channel follows",
+ async (_, routed) => {
+ const { navigator, confirmWelcome } = await starterConfirmedFirst(routed);
+ // Messages stays mounted with its visit behind Settings, as the host keeps it.
+ void navigator.open({ version: 1, kind: "settings" });
+ await confirmWelcome();
+ expectStarterKept(navigator, "settings");
+ },
+);
+
+it("leaves focus outside Messages where it is when the welcome channel replaces the starter channel", async () => {
+ const { confirmWelcome } = await starterConfirmedFirst();
+ render(Sidebar );
+ const sidebar = screen.getByRole("button", { name: "Sidebar" });
+ // Focus can move without a click or key press, as when a dialog returns it.
+ act(() => sidebar.focus());
await confirmWelcome();
- expect(screen.getByRole("textbox", { name: "Message #Alpha" })).toBeVisible();
- expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ expect(sidebar).toHaveFocus();
});
it("keeps the starter channel when the welcome channel follows after the opening window", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
- const { confirmWelcome } = await starterConfirmedFirst();
+ const { navigator, confirmWelcome } = await starterConfirmedFirst();
await act(() => vi.advanceTimersByTimeAsync(10_000));
await confirmWelcome();
- expect(
- screen.getByRole("textbox", { name: "Message #Alpha" }),
- ).toBeVisible();
- expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull();
+ expectStarterKept(navigator);
} finally {
vi.useRealTimers();
}
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 43c09e7943..ca927b2707 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -639,28 +639,46 @@ function ChannelWorkspace({
(emptyList && !noChannels));
// The relay's membership hints can confirm a new member's starter channel
// before their welcome channel, so a fallback chosen with no welcome channel
- // listed is provisional. Until the reader acts in this workspace or moves to
- // another visit, a welcome channel arriving within ENROLLMENT_WAIT_MS of the
- // list first loading replaces it.
+ // listed is provisional. Until the reader clicks or types anywhere or moves
+ // to another visit, a welcome channel arriving within ENROLLMENT_WAIT_MS of
+ // the list first loading replaces it.
const listed = useRef(undefined);
const provisional = useRef<{
channelId: string;
entryId: string | undefined;
+ /** Removes the input listeners that settle it. */
+ listening: AbortController;
}>(undefined);
+ const settleDefault = useCallback(() => {
+ provisional.current?.listening.abort();
+ provisional.current = undefined;
+ }, []);
+ useEffect(() => settleDefault, [settleDefault]);
useLayoutEffect(() => {
if (list.status === "ready") listed.current ??= Date.now();
if (
provisional.current &&
provisional.current.entryId !== navigation?.entryId
)
- provisional.current = undefined;
+ settleDefault();
// An open draft resolves nothing; keep the choice for when it closes.
if (requestedDestination) {
- if (!requestedChannel && requestedDestination === fallback)
+ if (!requestedChannel && requestedDestination === fallback) {
+ settleDefault();
+ // A click or key press anywhere settles it, such as tabbing through
+ // the sidebar or typing in a field outside Messages.
+ const listening = new AbortController();
+ for (const type of ["pointerdown", "keydown"])
+ document.addEventListener(type, settleDefault, {
+ capture: true,
+ signal: listening.signal,
+ });
provisional.current = {
channelId: requestedDestination.id,
entryId: navigation?.entryId,
+ listening,
};
+ }
chosen.current = requestedDestination.id;
}
visiblePreview.current =
@@ -668,28 +686,40 @@ function ChannelWorkspace({
? { channelId: current.id, queries }
: undefined;
});
- const settleDefault = () => {
- provisional.current = undefined;
- };
const welcomeId = welcomeChannel?.id;
const currentId = current?.id;
const visitFocus = navigation?.focus;
useEffect(() => {
const choice = provisional.current;
if (!choice || !welcomeId) return;
- provisional.current = undefined;
+ settleDefault();
+ const shown = navigator?.snapshot().entry;
if (
- currentId === choice.channelId &&
- welcomeId !== choice.channelId &&
- listed.current !== undefined &&
- Date.now() - listed.current < ENROLLMENT_WAIT_MS
+ currentId !== choice.channelId ||
+ welcomeId === choice.channelId ||
+ listed.current === undefined ||
+ Date.now() - listed.current >= ENROLLMENT_WAIT_MS ||
+ // Only the provisional visit while it is shown: never Settings over it,
+ // nor a visit opened since. An unrouted workspace has no visit to match,
+ // so only Settings is checked.
+ (choice.entryId === undefined
+ ? shown?.target.kind === "settings"
+ : shown?.id !== choice.entryId)
)
- // The same opening, corrected: no return path to the fallback.
- navigate(welcomeId, {
- replace: true,
- ...(visitFocus && { focus: visitFocus }),
- });
- }, [welcomeId, currentId, visitFocus, navigate]);
+ return;
+ // Focus the reader has moved out of Messages stays where it is.
+ const active = document.activeElement;
+ const focus =
+ active &&
+ active !== document.body &&
+ !historyContainer.current
+ ?.closest("[data-channel-workspace]")
+ ?.contains(active)
+ ? "preserve"
+ : visitFocus;
+ // The same opening, corrected: no return path to the fallback.
+ navigate(welcomeId, { replace: true, ...(focus && { focus }) });
+ }, [welcomeId, currentId, visitFocus, navigate, navigator, settleDefault]);
// Sidebar routing can update the same mounted page. Keep its saved default
// aligned with the resolved conversation, not only page-local clicks.
useEffect(() => {
@@ -2291,9 +2321,6 @@ function ChannelWorkspace({
ref={split.ref}
style={split.style}
className={`${styles.board} ${panelStyles.splitMotion}`}
- // The reader acting here keeps a provisional default; see `provisional`.
- onPointerDownCapture={settleDefault}
- onKeyDownCapture={settleDefault}
>
{current && membersOpen && (
]*$/.test(before)) return true;
- const after = before + text + source.slice(end);
+ const rest = source.slice(end);
+ if (!/(^|\s)$/.test(before) || !/^(\s|$)/.test(rest)) return false;
+ const after = before + text + rest;
const pasted = { start, end: start + text.length };
const spans = [
...markdownRanges(after).literal,
@@ -98,7 +100,7 @@ function pastedIntoMarkup(text: string, { source, start, end }: PasteAt) {
end: position?.end.offset ?? 0,
})),
];
- return spans.some(
+ return !spans.some(
(span) => pasted.start > span.start && pasted.end < span.end,
);
}
@@ -107,7 +109,7 @@ function pastedIntoMarkup(text: string, { source, start, end }: PasteAt) {
* A pasted npub, bare as Copy npub writes it or as a `nostr:` URI, becomes an
* identity link labelled with the person's name when it is known, so it shows
* as a chip. Like every identity link here, it notifies no one. Pasted into
- * Markdown code or a link, it stays as pasted.
+ * Markdown code or a link, or joined to other text, it stays as pasted.
*/
function identityPaste(
text: string,
@@ -117,7 +119,7 @@ function identityPaste(
const npub = text.trim().replace(/^nostr:/, "");
const target = `nostr:${npub}`;
const pubkey = profileKey(target);
- if (!pubkey || pastedIntoMarkup(text, at)) return text;
+ if (!pubkey || !pastedAlone(text, at)) return text;
const name = names?.resolve(pubkey) ?? formatPublicKey(pubkey) ?? npub;
return profileMentionLink(`@${name}`, target);
}
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 3810188e44..1fddc6e34f 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -422,18 +422,23 @@ it("keeps an npub pasted as a link destination being typed, so the link complete
);
});
-it("keeps an npub pasted between backticks as literal code", async () => {
+it.each([
+ ["inside a code span", "Key: `id ", " here`"],
+ ["after an unclosed backtick", "Key: `", ""],
+ ["inside a link label", "[see ", " here](https://example.com)"],
+ ["completing a bare URL", "https://primal.net/p/", ""],
+])("keeps an npub pasted %s as pasted", async (_, before, after) => {
const alice = "b".repeat(64);
const npub = npubEncode(alice);
const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- pasteIntoMessage(npub, "Key: `", "`");
- expect(messageField()).toHaveValue(`Key: \`${npub}\``);
+ pasteIntoMessage(npub, before, after);
+ expect(messageField()).toHaveValue(before + npub + after);
await user.click(await screen.findByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
lobby,
- welcome(`Key: \`${npub}\``),
+ welcome(before + npub + after),
expect.any(AbortSignal),
expect.any(Function),
),
From 0a1eb4f1094bada8ab02e782f3ad48b392b71980 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 19:36:58 +1100
Subject: [PATCH 11/28] fix(communities): keep a provisional welcome choice
through a route handoff
Review follow-up for the provisional welcome switch.
A starter channel chosen while a routed visit is still pending is
resolved into that visit, and the host's handoff passes Messages no
navigation for one commit before restoring the same history entry. The
render-time check compared the choice's entry with that missing
navigation and settled it, so a welcome channel confirmed later never
replaced the starter channel. The choice now settles only when Messages
is shown a different visit. The switch still replaces only the
provisional visit while it is shown, so Settings and later visits stay
protected.
New test, written but not run: the starter channel is in the first list
while the routed visit is pending; the page is re-rendered without
navigation and then with a conversation for the same entry, and the
welcome channel replaces the opening with no Back entry. Traced, not
run, to fail on the parent commit. starterConfirmedFirst takes options
for this. tsc --noEmit and biome check pass.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
.../channels/ChannelsPage.tabs.test.tsx | 59 ++++++++++++++-----
src/bundled/channels/ChannelsPage.tsx | 5 +-
2 files changed, 48 insertions(+), 16 deletions(-)
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index e3ba5af6ff..6694cac280 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -667,13 +667,28 @@ it("selects a default channel the relay adds after the page opened", async () =>
});
/** A new member's empty first list, then a membership hint confirming their
- * starter channel on its own read, as the relay's hints can arrive. Routed,
- * the page shows the navigator's current visit, as the host routes Messages. */
-async function starterConfirmedFirst(routed = false) {
+ * starter channel on its own read, as the relay's hints can arrive; with
+ * `listedFirst`, the first list already has it. Routed, the page shows the
+ * navigator's current visit, as the host routes Messages. */
+async function starterConfirmedFirst({
+ routed = false,
+ listedFirst = false,
+} = {}) {
const viewer = keypair(),
authority = keypair();
- const events: RelayEvent[] = [];
- const { owner, navigator, visit } = workspace(events, viewer, authority);
+ const starter = [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [
+ ["t", "stream"],
+ ["auto_join"],
+ ]),
+ ];
+ const events: RelayEvent[] = listedFirst ? [...starter] : [];
+ const { owner, scope, navigator, visit } = workspace(
+ events,
+ viewer,
+ authority,
+ );
if (routed)
visit(
{
@@ -687,14 +702,10 @@ async function starterConfirmedFirst(routed = false) {
await vi.waitFor(() =>
expect(owner.session.channels.list().status).toBe("ready"),
);
- events.push(
- roster(authority, "alpha", [viewer.pubkey]),
- metadata(authority, "alpha", "Alpha", undefined, [
- ["t", "stream"],
- ["auto_join"],
- ]),
- );
- await act(() => owner.session.channels.resolve?.(["alpha"]));
+ if (!listedFirst) {
+ events.push(...starter);
+ await act(() => owner.session.channels.resolve?.(["alpha"]));
+ }
const alpha = await screen.findByRole("textbox", { name: "Message #Alpha" });
/** The welcome channel's hint, confirmed on a later read. Returns once the
* page has rendered the confirmed list and run its effects, so the switch
@@ -716,7 +727,7 @@ async function starterConfirmedFirst(routed = false) {
);
await act(async () => {});
};
- return { alpha, navigator, confirmWelcome };
+ return { alpha, scope, navigator, visit, confirmWelcome };
}
/** The starter channel kept: shown, and its history entry untouched. */
@@ -740,6 +751,22 @@ it("opens a welcome channel confirmed after the starter channel it opened first"
expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
});
+it("opens a welcome channel that follows the starter channel across a routed visit's handoff", async () => {
+ // The starter channel is chosen while the routed visit is still pending.
+ const { scope, navigator, visit, confirmWelcome } =
+ await starterConfirmedFirst({ routed: true, listedFirst: true });
+ const entryId = navigator.snapshot().entry.id;
+ // Resolving the visit to it hands the route off: one commit without
+ // navigation, then the same history entry as a conversation.
+ visit();
+ visit(conversationTarget(scope, "alpha"), entryId);
+ await confirmWelcome();
+ expect(navigator.snapshot()).toMatchObject({
+ canGoBack: false,
+ entry: { target: { kind: "conversation", channelId: "beta" } },
+ });
+});
+
it.each([
[
"clicks in it",
@@ -772,7 +799,9 @@ it.each([
])(
"leaves Settings open over %s starter channel when the welcome channel follows",
async (_, routed) => {
- const { navigator, confirmWelcome } = await starterConfirmedFirst(routed);
+ const { navigator, confirmWelcome } = await starterConfirmedFirst({
+ routed,
+ });
// Messages stays mounted with its visit behind Settings, as the host keeps it.
void navigator.open({ version: 1, kind: "settings" });
await confirmWelcome();
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index ca927b2707..e6882228bb 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -656,9 +656,12 @@ function ChannelWorkspace({
useEffect(() => settleDefault, [settleDefault]);
useLayoutEffect(() => {
if (list.status === "ready") listed.current ??= Date.now();
+ // The route handoff briefly passes no navigation; only another visit
+ // settles the choice.
if (
+ navigation &&
provisional.current &&
- provisional.current.entryId !== navigation?.entryId
+ provisional.current.entryId !== navigation.entryId
)
settleDefault();
// An open draft resolves nothing; keep the choice for when it closes.
From 6373e2b09f937b36147c391b1b3317b406a24f71 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 20:52:38 +1100
Subject: [PATCH 12/28] fix(relay): show a confirmed starter flag in the cached
directory
A confirmed auto-join readback, or a strong metadata read, now updates
the cached public directory (and the list) of this session epoch, so
every directory consumer shows the new flag without waiting up to 30 s
for the next directory read. A read in flight keeps the update, and a
failed read hands it back to the prior entry. readLanding also reports
the channel's starter flag, for Welcome settings to check an
unconfirmed starter change.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
src/features/relay/channel-lifecycle.test.ts | 42 +++++++++----
src/features/relay/channel-lifecycle.ts | 22 ++++++-
src/features/relay/session.ts | 1 +
src/features/relay/store-discovery.test.ts | 62 ++++++++++++++++++++
src/features/relay/store.ts | 42 +++++++++++++
5 files changed, 156 insertions(+), 13 deletions(-)
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index ba0ba3d3f9..2ec6132567 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -136,6 +136,7 @@ function harness(role = "owner", type = "stream", owners = 1) {
});
const removed = vi.fn();
const acceptDiscovery = vi.fn();
+ const acceptDirectory = vi.fn();
const owner = createChannelLifecycle({
reader: { read },
writer: { sign, publish },
@@ -144,6 +145,7 @@ function harness(role = "owner", type = "stream", owners = 1) {
canAccess: () => active,
removed,
acceptDiscovery,
+ acceptDirectory,
});
return {
owner,
@@ -152,6 +154,7 @@ function harness(role = "owner", type = "stream", owners = 1) {
publish,
removed,
acceptDiscovery,
+ acceptDirectory,
record,
roles,
metadata,
@@ -2212,6 +2215,32 @@ describe("auto-join switch", () => {
"#d": [id],
});
expect(h.acceptDiscovery).not.toHaveBeenCalled();
+ // The browsing directory lists it, so it takes the confirmed flag.
+ expect(h.acceptDirectory).toHaveBeenCalledOnce();
+ expect(
+ h.acceptDirectory.mock.calls[0]?.[0]?.[0]?.tags.some(
+ ([key]: string[]) => key === "auto_join",
+ ),
+ ).toBe(true);
+ } finally {
+ h.owner.dispose();
+ }
+ });
+
+ it("hands a strong metadata read to the directory, and to discovery only for a channel the viewer is in", async () => {
+ const h = publicStream();
+ try {
+ h.setEvents([h.metadata(true), ...h.getEvents().slice(1)]);
+ // A check of a starter step reads its flag from the same read.
+ await expect(h.owner.capability.readLanding(id)).resolves.toMatchObject({
+ autoJoin: true,
+ });
+ expect(h.acceptDiscovery).toHaveBeenCalledOnce();
+ expect(h.acceptDirectory).toHaveBeenCalledOnce();
+ h.deny();
+ await h.owner.capability.readLanding(id);
+ expect(h.acceptDiscovery).toHaveBeenCalledOnce();
+ expect(h.acceptDirectory).toHaveBeenCalledTimes(2);
} finally {
h.owner.dispose();
}
@@ -2370,21 +2399,12 @@ describe("welcome channel", () => {
const h = harness("member");
h.publish.mockImplementation(async () => {
h.setEvents([
- h.record(39000, [
- ["d", id],
- ["t", "stream"],
- ["public"],
- ["landing"],
- ]),
+ h.record(39000, [["d", id], ["t", "stream"], ["public"], ["landing"]]),
...h.getEvents().slice(1),
]);
});
h.setEvents([
- h.record(39000, [
- ["d", id],
- ["t", "stream"],
- ["public"],
- ]),
+ h.record(39000, [["d", id], ["t", "stream"], ["public"]]),
...h.getEvents().slice(1),
]);
try {
diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts
index b7860c8f17..cae961a89a 100644
--- a/src/features/relay/channel-lifecycle.ts
+++ b/src/features/relay/channel-lifecycle.ts
@@ -42,6 +42,8 @@ export type ChannelLanding = Readonly<{
welcome?: string;
welcomeTitle?: string;
name?: string;
+ /** A starter channel: the relay adds people who join to it. */
+ autoJoin?: true;
}>;
export type DmVisibility = Readonly<{
@@ -92,6 +94,7 @@ export function createChannelLifecycle({
relayAuthor,
canAccess,
acceptDiscovery,
+ acceptDirectory,
removed,
}: {
reader?: RelayReader | undefined;
@@ -100,6 +103,8 @@ export function createChannelLifecycle({
relayAuthor: string;
canAccess(id: string): boolean;
acceptDiscovery(events: readonly RelayEvent[]): void;
+ /** Display-only directory metadata, for any open channel. */
+ acceptDirectory?: ((events: readonly RelayEvent[]) => void) | undefined;
removed(id: string): void;
}) {
let closed = false;
@@ -207,7 +212,11 @@ export function createChannelLifecycle({
issued.set(id, template.created_at);
return template;
}
- async function sign(signer: RelayWriter, template: EventTemplate, signal: AbortSignal) {
+ async function sign(
+ signer: RelayWriter,
+ template: EventTemplate,
+ signal: AbortSignal,
+ ) {
const signed = await signer.sign(structuredClone(template), signal);
signal.throwIfAborted();
if (
@@ -573,8 +582,10 @@ export function createChannelLifecycle({
record.tags.some(([key]) => key === "auto_join") ===
(action === "auto-join-on")
) {
- // Only a channel this viewer reads joins the discovery state.
+ // Only a channel this viewer reads joins the discovery state;
+ // the browsing directory lists every open channel.
if (canAccess(id)) acceptDiscovery([record]);
+ acceptDirectory?.([record]);
return;
}
} else if (action === "archive" || action === "unarchive") {
@@ -616,10 +627,17 @@ export function createChannelLifecycle({
retryRead(async () => {
const record = await readMetadata(id, signal, true);
if (!record) return undefined;
+ // A strong read is current: the channel's projections take it too,
+ // so a check also refreshes its starter flag.
+ if (canAccess(id)) acceptDiscovery([record]);
+ acceptDirectory?.([record]);
const name = metadataName(record);
return Object.freeze({
...landingTags(record.tags),
...(name ? { name } : {}),
+ ...(record.tags.some(([key]) => key === "auto_join")
+ ? { autoJoin: true as const }
+ : {}),
});
}, signal),
);
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 0074781ea5..ba035f4aec 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1176,6 +1176,7 @@ export function createRelaySession(
relayAuthor: transport?.relayAuthor ?? "",
canAccess: (id) => !closed && channels.canParticipate(id),
acceptDiscovery: (events) => channels.acceptDiscovery(events),
+ acceptDirectory: (events) => channels.acceptDirectory(events),
removed: (id) => {
channels.denyChannel(id, new Error("Channel is no longer available"));
dropStrandedSends();
diff --git a/src/features/relay/store-discovery.test.ts b/src/features/relay/store-discovery.test.ts
index bd667ef42c..3d89e30bac 100644
--- a/src/features/relay/store-discovery.test.ts
+++ b/src/features/relay/store-discovery.test.ts
@@ -9,6 +9,7 @@ import { createRelaySession } from "./session";
import type { ChannelSummary } from "./contracts";
import type { ChannelStoreOptions } from "./store";
import { isRetryableRead } from "./read-retry";
+import { matchesEvent } from "./projection";
import {
flush,
keypair,
@@ -1494,6 +1495,67 @@ it("reads the open directory for browsing with descriptions, sharing the search
expect(h.pending).toHaveLength(0);
});
+it("shows a confirmed auto-join change in the directory before any directory re-read", async () => {
+ // An owner or admin flags channels they are not in, so the directory, not
+ // the list, carries this one.
+ const admin = keypair();
+ const id = "13050000-0000-4000-8000-000000000001";
+ let flagged = true;
+ const channel = () =>
+ metadata(relay, id, "starter", flagged ? 1_700_000_000 : 1_700_000_001, [
+ ["public"],
+ ["t", "stream"],
+ ...(flagged ? [["auto_join"]] : []),
+ ]);
+ const directoryReads = vi.fn();
+ const owner = createRelaySession(
+ {
+ viewer: admin.pubkey,
+ relayAuthor: relay.pubkey,
+ media: () => undefined,
+ query: async (filters) => {
+ if (
+ filters.some((filter) => filter.kinds?.[0] === 39000 && !filter["#d"])
+ )
+ directoryReads();
+ return [channel()].filter((event) =>
+ filters.some((filter) => matchesEvent(event, filter)),
+ );
+ },
+ channelLifecycle: {
+ sign: async (template) => signed(admin, template),
+ publish: async (event) => {
+ if (event.kind === 9002) flagged = false;
+ },
+ },
+ },
+ { outboxStorage: { load: () => [], save() {} } },
+ );
+ owners.push(owner);
+ const { channels } = owner.session;
+ channels.ensureList();
+ await vi.waitFor(() => expect(channels.list().status).toBe("ready"));
+ await channels.readDirectory?.();
+ expect(channels.browseDirectory?.()).toEqual([
+ { id, name: "starter", channelType: "stream", autoJoin: true },
+ ]);
+ const listeners = vi.fn();
+ const off = channels.subscribeDirectory?.(listeners);
+ try {
+ await owner.session.channelLifecycle.run("auto-join-off", id);
+ // Within the reuse window: the readback, not another directory read.
+ const unflagged = [{ id, name: "starter", channelType: "stream" }];
+ expect(channels.browseDirectory?.()).toEqual(unflagged);
+ expect(channels.openDirectory?.()).toEqual(unflagged);
+ expect(listeners).toHaveBeenCalled();
+ expect(await channels.readDirectory?.()).toEqual({ partial: false });
+ expect(channels.browseDirectory?.()).toEqual(unflagged);
+ expect(directoryReads).toHaveBeenCalledOnce();
+ } finally {
+ off?.();
+ }
+});
+
it("shows a first directory read's pages to browsing only, and names them for references once it finishes", async () => {
const h = setup({ publicDirectoryPage: 2, maxPublicDirectory: 10 });
h.channels.ensureList();
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index 4329eacf8c..75618a3be3 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -2351,6 +2351,9 @@ export function createChannelStore(
open?: ReturnType>;
/** A first read's open channels so far, for browsing only. */
pages?: ReturnType>;
+ /** Metadata confirmed outside this read while it runs, which a
+ * failure hands back to the prior read. */
+ accepted?: RelayEvent[];
}
| undefined;
const noOpenDirectory: ReturnType<
@@ -2568,6 +2571,7 @@ export function createChannelStore(
entry.partial = partial;
delete entry.previous;
delete entry.pages;
+ delete entry.accepted;
// Name references in mounted rows read the directory through the
// list subscription; the list itself is unchanged.
entry.open = openDirectoryChannels(entry.events);
@@ -2580,6 +2584,7 @@ export function createChannelStore(
if (publicDirectory === entry) {
publicDirectory = prior;
if (entry.pages) notify(directoryListeners);
+ if (entry.accepted) acceptDirectory(entry.accepted);
}
land();
},
@@ -2587,6 +2592,42 @@ export function createChannelStore(
}
return publicDirectory;
}
+ /** Fold relay-signed metadata confirmed outside a directory read, such as
+ * a lifecycle command's readback, into this epoch's directory, so browsing
+ * and name references show it before the next read. The read in flight, or
+ * the next one, keeps it unless the relay has newer metadata. Display only,
+ * like the directory itself. */
+ function acceptDirectory(events: readonly RelayEvent[]) {
+ const entry = publicDirectory;
+ if (disposed || !transport || entry?.generation !== epoch) return;
+ const author = transport.relayAuthor;
+ const fresh = events.filter(
+ (event) =>
+ event.kind === 39000 && event.pubkey === author && !!tag(event, "d"),
+ );
+ if (!fresh.length) return;
+ entry.events.push(...fresh);
+ if (entry.at !== undefined)
+ entry.open = openDirectoryChannels(entry.events);
+ else {
+ // The read folds them in when it finishes; until then, patch the rows
+ // shown meanwhile.
+ entry.accepted = [...(entry.accepted ?? []), ...fresh];
+ const latest = latestPublicMetadata(fresh);
+ const patch = (rows: readonly DirectoryChannel[]) =>
+ Object.freeze(
+ rows.flatMap((row) => {
+ const event = latest.get(row.id);
+ const channel = event ? directoryChannel(row.id, event) : row;
+ return channel ? [channel] : [];
+ }),
+ );
+ if (entry.open) entry.open = patch(entry.open);
+ if (entry.pages) entry.pages = patch(entry.pages);
+ }
+ notify(listListeners);
+ notify(directoryListeners);
+ }
/** Read the whole open-channel directory, for browsing. Shares the search
* read; `openDirectory` then names its channels. Never resolves them. */
async function readDirectory(
@@ -4178,6 +4219,7 @@ export function createChannelStore(
},
suspendedPreviews: () => discovery?.suspendedChannels() ?? [],
acceptDiscovery: applyDiscovery,
+ acceptDirectory,
accept,
clearCache,
dispose,
From 2bc034d269b865fb6d174c42c7a74f8a17c067e1 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 20:52:59 +1100
Subject: [PATCH 13/28] fix(communities): save starter changes with the
welcome, drop the preview
Welcome settings no longer shows a preview of the card. Add and Remove
now edit a draft starter set instead of publishing at once: Save
publishes the welcome first, if it changed, then each changed starter
flag in turn, and Cancel discards both. A drafted addition is listed
as not saved yet and a drafted removal leaves the list. Save stops at
a refused step: earlier steps stay published, the refused change shows
its reason beside its row or on the search, and later changes stay in
the draft. An unconfirmed starter step uses the same Check again flow,
reading that channel again. One "Welcome saved" notice replaces the
per-change ones.
The default title names the community only by a configured name, this
viewer's own or the relay's published one; a community known only by
its address gets "Welcome". Validation follows the relay: text of only
White_Space and invisible code points is empty, and bidirectional
marks, embeddings, overrides and isolates are refused.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 10 +-
docs/communities.md | 39 +-
src/bundled/moderation/README.md | 6 +-
.../moderation/WelcomeMessageEditor.tsx | 6 +-
.../moderation/WelcomeSettings.module.css | 2 +-
.../moderation/WelcomeSettings.test.tsx | 474 ++++++++-----
src/bundled/moderation/WelcomeSettings.tsx | 643 ++++++++++--------
src/bundled/moderation/index.tsx | 7 +-
src/bundled/moderation/registration.test.ts | 12 +-
src/features/communities/WelcomeCard.test.tsx | 45 +-
src/features/communities/WelcomeCard.tsx | 58 +-
src/features/relay/landing.test.ts | 59 +-
src/features/relay/landing.ts | 33 +-
13 files changed, 860 insertions(+), 534 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index cb06e2315a..800fc5bda6 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -469,8 +469,10 @@ revokes all of them, and a pairing unused for 30 days expires.
a new member's channels arrive, Messages says it is opening them. If none
arrive within a few seconds, **Try again** checks for them again. Owners and admins choose the channel and edit
the card's title and message in **Settings → Community admin → Welcome**. The
- message editor has the composer's formatting toolbar and shortcuts, and a
- preview below it. The message is saved as Markdown, up to 4,000 characters,
+ message editor has the composer's formatting toolbar and shortcuts. The
+ default title names the community when the relay or you have given it a
+ name, and is just "Welcome" otherwise. The message is saved as Markdown, up
+ to 4,000 characters,
and a published message opens as its Markdown source; identity links name
people without notifying them. Paste a channel's **Copy link** to link the
channel, or a person's **Copy npub** to link the person. People already in
@@ -479,7 +481,9 @@ revokes all of them, and a pairing unused for 30 days expires.
choose which other open channels new members join, even on relays without a
welcome channel. Only starter channels are listed, each with **Remove**;
**Add a channel** searches the others. Until you type a name, this picker and
- the welcome channel picker list the first 50 channels. A change applies to people who join
+ the welcome channel picker list the first 50 channels. Adding and removing
+ change the draft; **Save** publishes them with the welcome, and **Cancel**
+ discards them. A saved change applies to people who join
afterwards, not to current members; in an open community it also adds
returning people who were never in that channel. Archiving a starter channel
or making it private pauses it until it is public again.
diff --git a/docs/communities.md b/docs/communities.md
index 8680235a07..6b56a578cb 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -182,8 +182,10 @@ with a strong metadata read. If nothing is designated yet, the channel field
starts empty and must be chosen before Save. The picker lists public, unarchived
stream channels.
-The title starts with the custom title or the default, "Welcome to" and the
-viewer's own name for the community: 1–120 characters on one line. The message starts with the
+The title starts with the custom title or the default: "Welcome to" and the
+community's configured name, the one this viewer gave it or the relay's
+published one, or just "Welcome" when the community is known only by its
+address. A title is 1–120 characters on one line. The message starts with the
custom text or the default. It is edited in the message composer's editor, with
its formatting toolbar, shortcuts and link dialog; Enter adds a line break, and
there are no mentions, attachments, GIFs or cards. The message is stored as
@@ -197,18 +199,23 @@ the start or end of the message on each side, outside Markdown code and links.
Anywhere else, such as after `[@Name](`, an unclosed backtick or a URL being
typed, it stays as pasted.
Channel or message links pasted from **Copy link** show as chips in the editor,
-and as linked names in the preview, and open in place for members. It takes
+and as linked names in the card, where they open in place for members. It takes
1–4,000 characters, with no control characters other than line breaks and tabs.
-Both are checked against the relay's rules as the admin types. Line endings are
+Both are checked against the relay's rules as the admin types: text made only
+of spaces and invisible characters, such as zero-width spaces or the Braille
+blank, counts as empty, and hidden text-direction marks, embeddings, overrides
+and isolates are refused anywhere. Line endings are
normalized and both are trimmed before sending. A message matching the default
is saved as the default rather than as custom text. The default title depends on
who is looking, so it is never inferred: an edited title is saved as written, an
untouched one stays as published, and **Restore defaults** resets both in the
-draft and saves the default title. A preview shows the card as members will see it, with
-its links inert. Save sends the whole state and confirms it as described in
-[channel lifecycle](channels.md#channel-lifecycle). A relay refusal is shown with
-its reason and the draft is kept. A save the relay has not shown yet keeps the
-draft with Save disabled until **Check again** reads the relay's state. Archive,
+draft and saves the default title. Nothing is published until **Save**. Save
+sends the welcome's whole state if it changed, then each changed starter flag
+in turn, and confirms each step as described in
+[channel lifecycle](channels.md#channel-lifecycle); one "Welcome saved" notice
+follows a complete save. A relay refusal of the welcome is shown with its reason
+and the draft is kept. A step the relay has not shown yet keeps the draft with
+Save disabled until **Check again** reads the relay's state for that step. Archive,
Delete, Make private and expiry are not disabled for the welcome channel yet; the
relay refuses them, and that refusal is shown like any other lifecycle error.
@@ -216,11 +223,15 @@ relay refuses them, and that refusal is shown like any other lifecycle error.
however many channels the community has. The welcome channel comes first, fixed,
because new members always join it; its own flag is left unchanged. Each other
starter channel has **Remove**, and **Add a channel** searches the community's
-open, unarchived stream channels that are not starters yet. Both take effect at
-once: an added channel is listed while the relay confirms it, and a removed one
-stays until it does. A refusal or a prompt to try again shows beside a removed
-channel's row, or on the search for an added one. One change runs at a time, and
-Save waits for it. Owners and admins can add a channel they are not in. Adding a
+open, unarchived stream channels that are not starters yet. Both edit the draft,
+and Save and Cancel appear for starter changes alone: an added channel is listed
+as not saved yet, and a removed one leaves the list until Cancel brings it back.
+Save stops at a refused step. The steps before it are published, the refused
+change returns to its published state with the relay's reason beside its row
+for a removal or on the search for an addition, and the changes after it stay in
+the draft. A confirmed change shows at once wherever the directory or roster
+lists the channel, without waiting for the next directory read. Owners and
+admins can add a channel they are not in. Adding a
channel applies to people who join from then on, not current members; in an
open community it also adds people who come back and have never been in that
channel.
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 9dcaca0006..aed48fce7c 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -27,5 +27,7 @@ channels.
Below that, **Also add new members to** lists the starter channels, which new
members join too: **Add a channel** searches other open channels, and **Remove**
-takes one off. Each change applies at once, to people who join afterwards. The
-list also appears on relays without a welcome channel.
+takes one off. These edit the same draft as the welcome: **Save** publishes
+the welcome first, then each starter change, and **Cancel** discards them.
+Saved changes apply to people who join afterwards. The list, with its Save,
+also appears on relays without a welcome channel.
diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx
index a004b4f865..d90d4241f8 100644
--- a/src/bundled/moderation/WelcomeMessageEditor.tsx
+++ b/src/bundled/moderation/WelcomeMessageEditor.tsx
@@ -50,9 +50,9 @@ const LinkToken = memo(WelcomeMessage);
* Identity, channel, message and web links outside code: the spans the editor
* shows as chips, as the composer does. This is deliberately narrower than
* `RichComposerInput`'s `decorationsFor`: plain `#name` and `@Name`
- * references and `:shortcode:` emoji are not chips. The preview and card
- * render through `MessageMarkdown`, so they may show a reference that stays
- * plain text here.
+ * references and `:shortcode:` emoji are not chips. The card renders
+ * through `MessageMarkdown`, so it may show a reference that stays plain
+ * text here.
*/
function linkRanges(text: string, rich: readonly SourceRange[]) {
const blocked = [...rich, ...markdownRanges(text).literal];
diff --git a/src/bundled/moderation/WelcomeSettings.module.css b/src/bundled/moderation/WelcomeSettings.module.css
index 2bb924413b..6844b32952 100644
--- a/src/bundled/moderation/WelcomeSettings.module.css
+++ b/src/bundled/moderation/WelcomeSettings.module.css
@@ -11,7 +11,7 @@
gap: var(--space-4);
min-width: 0;
}
-.preview {
+.section {
display: grid;
gap: var(--space-3);
min-width: 0;
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 1fddc6e34f..3af04816fd 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -16,11 +16,11 @@ import {
communityRequest,
type CommunityInfo,
} from "../../features/communities/api";
-import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard";
import type {
- ConversationExtensions,
- LinkRenderer,
-} from "../../features/conversation/contracts";
+ CommunityReader,
+ Membership,
+} from "../../features/communities/service";
+import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard";
import type { ComposerInputElement } from "../../features/messages/composer-dom";
import { composerDOMFixture } from "../../features/messages/composer-testing";
import { profileTarget } from "../../features/profiles/target";
@@ -33,9 +33,7 @@ import type { ChannelSummary } from "../../features/relay/contracts";
import { PublishRejected } from "../../features/relay/outbox";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
-import type { Contribution } from "../../plugins/contributions";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
-import { apply as applyLinks } from "../links/index";
import { WelcomeSettings } from "./WelcomeSettings";
vi.mock("../../features/communities/api", async (importOriginal) => ({
@@ -54,7 +52,14 @@ const owner = "0".repeat(64);
const lobby = "11111111-1111-4111-8111-111111111111";
const square = "22222222-2222-4222-8222-222222222222";
const random = "55555555-5555-4555-8555-555555555555";
-const rows = [
+type DirectoryRow = Readonly<{
+ id: string;
+ name: string;
+ channelType: "stream" | "forum";
+ autoJoin?: true;
+ archived?: true;
+}>;
+const rows: DirectoryRow[] = [
{ id: lobby, name: "Lobby", channelType: "stream" as const },
{
id: square,
@@ -80,53 +85,53 @@ const rows = [
},
];
-/** The conversation renderers the app provides, with the bundled Links
- * renderer that draws Buzz and web links. */
-function linkExtensions(): ConversationExtensions {
- const links: Contribution[] = [];
- applyLinks({
- conversation: {
- registerLink: (renderer: LinkRenderer) =>
- links.push({
- ...renderer,
- key: `buzz.links/${renderer.id}`,
- pluginId: "buzz.links",
- revision: "one",
- }),
- },
- } as never);
- const none: readonly never[] = [];
- const reader = (entries: readonly T[]) => ({
- snapshot: () => entries,
- subscribe: () => () => {},
- });
- return { links: reader(links), tools: reader(none), inline: reader(none) };
-}
-
function setup({
info = { policy: null, landingChannels: true, landingChannel: lobby },
landing = { landing: true, welcome: "Hi there", name: "Lobby" },
joined = [],
directory = rows,
- extensions,
names,
+ membership = { name: "Primary" },
}: {
info?: CommunityInfo;
landing?: ChannelLanding | undefined;
/** The viewer's own channels, beside the directory. */
joined?: ChannelSummary[];
- directory?: typeof rows;
- extensions?: ConversationExtensions;
+ directory?: DirectoryRow[];
/** Known names by public key. */
names?: Readonly>;
+ /** How the viewer's saved membership names the community. */
+ membership?: Omit;
} = {}) {
- const readLanding = vi.fn(async () => landing);
+ const readLanding = vi.fn(
+ async () => landing,
+ );
const setLanding = vi.fn(
async () => {},
);
- const run = vi.fn(async () => {});
let list = { status: "ready", channels: joined };
+ let listed = directory;
const rosterListeners = new Set<() => void>();
+ const directoryListeners = new Set<() => void>();
+ /** The relay shows a channel's starter flag, as a strong read hands it to
+ * the roster and the directory. */
+ const flag = (id: string, enabled: boolean) => {
+ const set = (row: T): T => {
+ if (row.id !== id) return row;
+ const { autoJoin: _, ...rest } = row;
+ return (enabled ? { ...rest, autoJoin: true } : rest) as T;
+ };
+ listed = listed.map(set);
+ list = { ...list, channels: list.channels.map(set) };
+ act(() => {
+ for (const listener of [...rosterListeners, ...directoryListeners])
+ listener();
+ });
+ };
+ // A confirmed command's readback updates the flag before it resolves.
+ const run = vi.fn(async (action, id) =>
+ flag(id, action === "auto-join-on"),
+ );
const session = {
viewer: owner,
media: () => undefined,
@@ -146,9 +151,12 @@ function setup({
rosterListeners.add(listener);
return () => rosterListeners.delete(listener);
},
- subscribeDirectory: () => () => {},
- openDirectory: () => directory,
- browseDirectory: () => directory,
+ subscribeDirectory(listener: () => void) {
+ directoryListeners.add(listener);
+ return () => directoryListeners.delete(listener);
+ },
+ openDirectory: () => listed,
+ browseDirectory: () => listed,
readDirectory: async () => ({ partial: false }),
directoryComplete: () => true,
},
@@ -182,13 +190,18 @@ function setup({
for (const listener of listeners) listener();
});
};
+ const communities = {
+ snapshot: () => ({
+ memberships: [{ id: "https://primary.example", ...membership }],
+ }),
+ subscribe: () => () => {},
+ } as unknown as CommunityReader;
vi.mocked(communityRequest).mockResolvedValue(info);
const view = render(
true}
- extensions={extensions}
/>,
{ wrapper: ToastProvider },
);
@@ -196,6 +209,7 @@ function setup({
readLanding,
setLanding,
run,
+ flag,
setJoined,
reconnect,
view,
@@ -234,14 +248,11 @@ it("opens on the designated channel, its title and its message, read fresh", asy
expect.any(AbortSignal),
);
expect(readLanding).toHaveBeenCalledWith(lobby, expect.any(AbortSignal));
- expect(
- screen.getByRole("region", { name: "Welcome to Primary" }),
- ).toHaveTextContent("Hi there");
// Nothing changed yet, so there is nothing to save.
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
-it("opens on a custom title and renders the message as Markdown in the preview", async () => {
+it("opens on a custom title", async () => {
setup({
landing: {
landing: true,
@@ -251,8 +262,7 @@ it("opens on a custom title and renders the message as Markdown in the preview",
},
});
await waitFor(() => expect(titleField()).toHaveValue("Hello, newcomer"));
- const preview = screen.getByRole("region", { name: "Hello, newcomer" });
- expect(preview.querySelector("strong")).toHaveTextContent("hi");
+ expect(messageField()).toHaveValue("Say **hi**");
expect(
screen.getByText(
/Format with the toolbar or Markdown\. To link a channel or person, paste a copied channel link or npub\./,
@@ -283,8 +293,6 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
expect(input).toHaveFocus();
await user.keyboard("Join");
expect(input.querySelector("strong")).toHaveTextContent("Join");
- const preview = screen.getByRole("region", { name: "Welcome to Primary" });
- expect(preview.querySelector("strong")).toHaveTextContent("Join");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
@@ -296,35 +304,6 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
);
});
-it("draws channel links in the preview as the welcome card does", async () => {
- // A channel's Copy link writes the bare link; the joined list names it.
- setup({
- landing: {
- landing: true,
- welcome: `Say hi in buzz://channel/${square}`,
- name: "Lobby",
- },
- joined: [{ id: square, name: "Town square", channelType: "stream" }],
- extensions: linkExtensions(),
- });
- const preview = await screen.findByRole("region", {
- name: "Welcome to Primary",
- });
- await waitFor(() =>
- expect(preview.querySelector("[data-link-kind='channel']")).not.toBeNull(),
- );
- const link = preview.querySelector("[data-link-kind='channel']");
- // The channel icon supplies the hash, as in the timeline.
- expect(link).toHaveTextContent(/^Town square$/);
- expect(link?.querySelector("svg")).not.toBeNull();
- expect(link?.closest("[data-link-renderer]")).toHaveAttribute(
- "data-link-renderer",
- "buzz.links/links",
- );
- // The preview stays inert: no link to follow.
- expect(within(preview).queryByRole("link")).toBeNull();
-});
-
/** A plain-text clipboard holding `text`. */
const plainClipboard = (text: string) => ({
clipboardData: {
@@ -353,13 +332,7 @@ it("links a pasted npub to the person, named as their profile names them", async
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
// Copy npub writes the bare npub.
pasteIntoMessage(npub);
- const preview = screen.getByRole("region", { name: "Welcome to Primary" });
- await waitFor(() =>
- expect(preview.querySelector("[data-profile-target]")).toHaveTextContent(
- "Alice",
- ),
- );
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(await screen.findByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
lobby,
@@ -488,13 +461,6 @@ it("opens published Markdown as its source and sends it back unchanged", async (
landing: { landing: true, welcome: markdown, name: "Lobby" },
});
await waitFor(() => expect(messageField()).toHaveValue(markdown));
- const preview = screen.getByRole("region", { name: "Welcome to Primary" });
- expect(preview.querySelector("strong")).toHaveTextContent("Welcome");
- expect(preview.querySelector("em")).toHaveTextContent("Primary");
- expect(preview.querySelectorAll("li")).toHaveLength(2);
- expect(preview.querySelector("[data-profile-target]")).toHaveTextContent(
- "Ada",
- );
// Nothing changed yet, so there is nothing to save.
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
const input = messageField() as ComposerInputElement;
@@ -608,9 +574,6 @@ it("normalizes line endings and trims the title and message before sending", asy
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
fireEvent.change(messageField(), { target: { value: " Hi\r\nall " } });
fireEvent.change(titleField(), { target: { value: " Hello, newcomer " } });
- expect(
- screen.getByRole("region", { name: "Hello, newcomer" }),
- ).toHaveTextContent("Hi all");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenCalledWith(
@@ -677,6 +640,54 @@ it("blocks titles the relay would refuse", async () => {
expect(setLanding).not.toHaveBeenCalled();
});
+it("blocks invisible-only text and hidden direction characters, as the relay does", async () => {
+ const { setLanding } = setup();
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ // Zero-width and braille-blank characters alone leave nothing to read.
+ fireEvent.change(messageField(), { target: { value: "\u200B\u2800" } });
+ expect(
+ screen.getByText("Enter a welcome message, or restore the defaults."),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ fireEvent.change(messageField(), { target: { value: "\u202Eabc" } });
+ expect(
+ screen.getByText("Remove the hidden text-direction characters."),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ // A zero-width space inside readable text is fine.
+ fireEvent.change(messageField(), { target: { value: "Hello\u200Bworld" } });
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ fireEvent.change(titleField(), { target: { value: " \u200B " } });
+ expect(
+ screen.getByText("Enter a title, or restore the defaults."),
+ ).toBeVisible();
+ fireEvent.change(titleField(), { target: { value: "Hi\u2066there" } });
+ expect(
+ screen.getByText("Remove the hidden text-direction characters."),
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ expect(setLanding).not.toHaveBeenCalled();
+});
+
+it('titles the default "Welcome" for a community known only by its address', async () => {
+ // Joined without a published name, the membership holds only the host.
+ const { setLanding, user } = setup({
+ membership: { name: "primary.example" },
+ });
+ await waitFor(() => expect(titleField()).toHaveValue("Welcome"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ // An untouched default title is sent as the default.
+ await waitFor(() =>
+ expect(setLanding).toHaveBeenCalledWith(
+ lobby,
+ welcome("Say hello"),
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
+ );
+});
+
it("resends a published title untouched, even one matching this viewer's default", async () => {
// Another viewer may know the community by another name, so the default is
// never inferred from the text: only Restore defaults sends it.
@@ -912,7 +923,7 @@ it("lists only the starter channels, the welcome channel fixed first", async ()
expect(within(group).queryByText("Random")).toBeNull();
expect(within(group).queryByText("Staff")).toBeNull();
expect(group).toHaveTextContent(
- "A channel you add applies to people who join from now on",
+ "Changes apply when you save, to people who join from then on",
);
// The search offers the rest: neither the welcome channel nor a starter.
await user.click(
@@ -970,81 +981,107 @@ it("browses the first 50 channels by name in both pickers and finds the rest by
expect(screen.queryByText(hint)).toBeNull();
});
-it("adds a starter from the search through the channel lifecycle, listed while the relay confirms", async () => {
- const { run, user } = setup();
- await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
- let settle!: () => void;
+/** Holds the next starter command until `release`, as the relay confirms. */
+function holdRun(
+ run: ReturnType["run"],
+ flag: ReturnType["flag"],
+) {
+ let release!: () => void;
run.mockImplementationOnce(
- () =>
+ (action, id) =>
new Promise((resolve) => {
- settle = resolve;
+ release = () => {
+ flag(id, action === "auto-join-on");
+ resolve();
+ };
}),
);
+ return () => act(async () => release());
+}
+
+it("adds a starter to the draft and publishes it, after the welcome, on Save", async () => {
+ const { flag, run, setLanding, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
await user.type(addField(), "ran");
await user.click(await screen.findByRole("option", { name: "Random" }));
+ // Only the draft changes: the row is listed as not saved yet.
+ expect(run).not.toHaveBeenCalled();
+ expect(addField()).toHaveValue("");
+ expect(within(starters()).getByText("Added · not saved yet")).toBeVisible();
+ expect(remove("Random")).toBeEnabled();
+ // A starter change alone offers Save.
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ const release = holdRun(run, flag);
+ await user.click(screen.getByRole("button", { name: "Save" }));
try {
- expect(run).toHaveBeenCalledWith(
- "auto-join-on",
- random,
- expect.any(AbortSignal),
- expect.any(Function),
+ await waitFor(() =>
+ expect(run).toHaveBeenCalledWith(
+ "auto-join-on",
+ random,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ ),
);
- expect(addField()).toHaveValue("");
- // The new row shows at once; one channel command at a time, and the
- // welcome waits for it.
- expect(within(starters()).getByText("Adding…")).toBeVisible();
+ // The welcome is published first, then each starter flag.
+ expect(setLanding.mock.invocationCallOrder[0]).toBeLessThan(
+ run.mock.invocationCallOrder[0] ?? 0,
+ );
+ expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute(
+ "aria-busy",
+ "true",
+ );
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(remove("Random")).toBeDisabled();
expect(remove("Town square")).toBeDisabled();
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
} finally {
- await act(async () => settle());
+ await release();
}
- // The confirmed flag keeps the row; the directory is not yet republished.
- expect(await screen.findByText("Added #Random")).toBeInTheDocument();
- expect(within(starters()).queryByText("Adding…")).toBeNull();
+ // One notice for the whole save, and the row is now published.
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(screen.queryByText("Added #Random")).toBeNull();
+ expect(within(starters()).queryByText("Added · not saved yet")).toBeNull();
expect(remove("Random")).toBeEnabled();
- expect(remove("Town square")).toBeEnabled();
- expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(run).toHaveBeenCalledOnce();
await user.click(
screen.getByRole("button", { name: "Browse channels to add" }),
);
expect(await screen.findByText("No matching public channels.")).toBeVisible();
});
-it("removes a starter through the channel lifecycle, keeping its row until the relay confirms", async () => {
- const { run, user } = setup();
+it("drops a removed starter from the list until Cancel, and publishes the removal on Save", async () => {
+ const { run, setLanding, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
- let settle!: () => void;
- run.mockImplementationOnce(
- () =>
- new Promise((resolve) => {
- settle = resolve;
- }),
- );
await user.click(remove("Town square"));
- try {
- expect(run).toHaveBeenCalledWith(
- "auto-join-off",
- square,
- expect.any(AbortSignal),
- expect.any(Function),
- );
- expect(remove("Town square")).toHaveAttribute("aria-busy", "true");
- expect(remove("Town square")).toHaveFocus();
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- } finally {
- await act(async () => settle());
- }
// The row leaves with its button, so focus moves to the search.
+ expect(run).not.toHaveBeenCalled();
expect(removable("Town square")).toBeNull();
expect(addField()).toHaveFocus();
- expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- expect(await screen.findByText("Removed #Town square")).toBeInTheDocument();
expect(within(starters()).getByRole("status")).toHaveTextContent(
"No other channels added yet",
);
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(remove("Town square")).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ await user.click(remove("Town square"));
+ // Adding back a drafted removal leaves nothing to save.
+ await user.type(addField(), "town");
+ await user.click(await screen.findByRole("option", { name: "Town square" }));
+ expect(within(starters()).queryByText("Added · not saved yet")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ await user.click(remove("Town square"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ // An unchanged welcome is not republished.
+ expect(setLanding).not.toHaveBeenCalled();
+ expect(run).toHaveBeenCalledExactlyOnceWith(
+ "auto-join-off",
+ square,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ expect(removable("Town square")).toBeNull();
// Removed, it can be added again.
await user.click(
screen.getByRole("button", { name: "Browse channels to add" }),
@@ -1056,7 +1093,7 @@ it("removes a starter through the channel lifecycle, keeping its row until the r
]);
});
-it("follows the roster for a joined channel instead of its own confirmed state", async () => {
+it("follows the roster for a joined channel once its flag is saved", async () => {
const unlisted: ChannelSummary = {
id: "66666666-6666-4666-8666-666666666666",
name: "Unlisted",
@@ -1067,28 +1104,30 @@ it("follows the roster for a joined channel instead of its own confirmed state",
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
await user.type(addField(), "unl");
await user.click(await screen.findByRole("option", { name: "Unlisted" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
expect(run).toHaveBeenCalledOnce();
- // Every other control is held until the command settles.
- await waitFor(() => expect(remove("Town square")).toBeEnabled());
- // The readback republishes the joined channel; that is what the list shows.
- expect(removable("Unlisted")).toBeNull();
- setJoined([{ ...unlisted, autoJoin: true }]);
+ // The readback republished the joined channel; that is what the list shows.
expect(remove("Unlisted")).toBeVisible();
+ expect(within(starters()).queryByText("Added · not saved yet")).toBeNull();
// Another admin's later change shows too; nothing kept here overrides it.
setJoined([unlisted]);
expect(removable("Unlisted")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
-it("explains a failed removal beside its row and a failed addition on the search", async () => {
+it("explains a refused removal beside its row and a refused addition on the search", async () => {
const { run, user } = setup();
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
run.mockRejectedValueOnce(new PublishRejected("rejected"));
await user.click(remove("Town square"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Only community owners and admins can change this.",
);
- // The message follows its row, before the search.
+ // The refused removal is listed again, its reason after its row and
+ // before the search; nothing is left to save.
expect(
remove("Town square").compareDocumentPosition(alert) &
Node.DOCUMENT_POSITION_FOLLOWING,
@@ -1097,21 +1136,17 @@ it("explains a failed removal beside its row and a failed addition on the search
alert.compareDocumentPosition(addField()) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
- // Repeating the same change is how an unconfirmed one is checked.
- run.mockRejectedValueOnce(
- new ChannelLifecycleUnconfirmed(new Error("timeout")),
- );
- await user.click(remove("Town square"));
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Saved, but not confirmed yet. Try again to check.",
- );
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(screen.queryByText("Welcome saved")).toBeNull();
+ // Removing it again clears the reason.
await user.click(remove("Town square"));
- await waitFor(() => expect(removable("Town square")).toBeNull());
expect(screen.queryByRole("alert")).toBeNull();
- // A failed addition has no row, so the search explains it.
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ // A refused addition has no row, so the search explains it.
run.mockRejectedValueOnce(new PublishRejected("rejected"));
await user.type(addField(), "ran");
await user.click(await screen.findByRole("option", { name: "Random" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"#Random: Only community owners and admins can change this.",
);
@@ -1119,11 +1154,110 @@ it("explains a failed removal beside its row and a failed addition on the search
// Adding it again clears the reason.
await user.type(addField(), "ran");
await user.click(await screen.findByRole("option", { name: "Random" }));
- await waitFor(() => expect(remove("Random")).toBeEnabled());
+ expect(remove("Random")).toBeEnabled();
expect(screen.queryByRole("alert")).toBeNull();
});
-it("holds starter changes while the welcome saves", async () => {
+it("publishes the steps before a refusal and keeps the ones after it in the draft", async () => {
+ const unlisted: ChannelSummary = {
+ id: "66666666-6666-4666-8666-666666666666",
+ name: "Unlisted",
+ channelType: "stream",
+ visibility: "public",
+ };
+ const { run, setLanding, user } = setup({ joined: [unlisted] });
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ // Steps run by channel name: Random, Town square, then Unlisted.
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
+ await user.click(remove("Town square"));
+ await user.type(addField(), "unl");
+ await user.click(await screen.findByRole("option", { name: "Unlisted" }));
+ const confirm = run.getMockImplementation();
+ if (!confirm) throw new Error("The fixture confirms starter commands");
+ run
+ .mockImplementationOnce(confirm)
+ .mockRejectedValueOnce(
+ new PublishRejected("rejected", "invalid: channel is busy"),
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Channel is busy");
+ expect(setLanding).toHaveBeenCalledOnce();
+ expect(run.mock.calls.map(([action, id]) => [action, id])).toEqual([
+ ["auto-join-on", random],
+ ["auto-join-off", square],
+ ]);
+ // The welcome is published, so its draft is gone.
+ expect(messageField()).toHaveValue("Say hello");
+ expect(screen.queryByText("Welcome saved")).toBeNull();
+ // Random is published, the refused removal is listed again with its
+ // reason, and the step after it waits in the draft.
+ expect(remove("Random")).toBeVisible();
+ expect(remove("Town square")).toBeVisible();
+ const drafted = within(starters()).getAllByText("Added · not saved yet");
+ expect(drafted).toHaveLength(1);
+ expect(remove("Unlisted").closest(".buzz-preference-row")).toHaveTextContent(
+ "Added · not saved yet",
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(setLanding).toHaveBeenCalledOnce();
+ expect(run.mock.calls.slice(2).map(([action, id]) => [action, id])).toEqual([
+ ["auto-join-on", unlisted.id],
+ ]);
+ expect(within(starters()).queryByText("Added · not saved yet")).toBeNull();
+});
+
+it("checks an unconfirmed starter step by reading its channel again", async () => {
+ const { flag, readLanding, run, user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ readLanding.mockClear();
+ run.mockRejectedValueOnce(
+ new ChannelLifecycleUnconfirmed(new Error("timeout")),
+ );
+ await user.click(remove("Town square"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Not confirmed yet. The relay hasn’t shown the change to #Town square; check before saving again.",
+ );
+ // The removal stays drafted, but another save would republish blind.
+ expect(removable("Town square")).toBeNull();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ // The relay does not show it yet: Save opens again for the same draft.
+ readLanding.mockResolvedValueOnce({ name: "Town square", autoJoin: true });
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(),
+ );
+ expect(readLanding).toHaveBeenCalledExactlyOnceWith(
+ square,
+ expect.any(AbortSignal),
+ );
+ expect(screen.queryByText(/Not confirmed yet/)).toBeNull();
+ expect(screen.queryByText("Welcome saved")).toBeNull();
+ expect(removable("Town square")).toBeNull();
+ // Unconfirmed again, and this time the check finds it.
+ run.mockRejectedValueOnce(
+ new ChannelLifecycleUnconfirmed(new Error("timeout")),
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await screen.findByText(/Not confirmed yet/);
+ readLanding.mockImplementationOnce(async () => {
+ flag(square, false);
+ return { name: "Town square" };
+ });
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(screen.queryByText(/Not confirmed yet/)).toBeNull();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(removable("Town square")).toBeNull();
+ // The welcome form was not read again.
+ expect(communityRequest).toHaveBeenCalledOnce();
+});
+
+it("holds starter edits while the welcome saves", async () => {
const { setLanding, user } = setup();
let settle!: () => void;
setLanding.mockImplementationOnce(
@@ -1149,8 +1283,8 @@ it("holds starter changes while the welcome saves", async () => {
expect(remove("Town square")).toBeEnabled();
});
-it("still lists starter channels when the relay has no welcome channel", async () => {
- const { readLanding } = setup({
+it("still lists and saves starter channels when the relay has no welcome channel", async () => {
+ const { readLanding, run, user } = setup({
info: { policy: null, landingChannels: false },
});
expect(
@@ -1163,4 +1297,14 @@ it("still lists starter channels when the relay has no welcome channel", async (
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
expect(screen.queryByText("Welcome channel · always joined")).toBeNull();
expect(readLanding).not.toHaveBeenCalled();
+ await user.click(remove("Town square"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(run).toHaveBeenCalledExactlyOnceWith(
+ "auto-join-off",
+ square,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ );
+ expect(removable("Town square")).toBeNull();
});
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 60d9ffb8a7..919be54b65 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -1,23 +1,26 @@
-import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
+import {
+ Fragment,
+ type RefObject,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import type { ComposerInputElement } from "../../features/messages/composer-dom";
import {
communityRequest,
type CommunityInfo,
} from "../../features/communities/api";
+import type { CommunityReader } from "../../features/communities/service";
import {
DEFAULT_WELCOME_MESSAGE,
- defaultWelcomeTitle,
- WelcomeCard,
- WelcomeMessage,
+ useDefaultWelcomeTitle,
} from "../../features/communities/WelcomeCard";
import { channelIcon } from "../../features/channels/channel-icon";
import { useChannelDirectory } from "../../features/channels/useChannelDirectory";
-import {
- ChannelLifecycleUnconfirmed,
- type ChannelLifecycleCapability,
-} from "../../features/relay/channel-lifecycle";
+import { ChannelLifecycleUnconfirmed } from "../../features/relay/channel-lifecycle";
import type { WelcomeCommand } from "../../features/relay/channel-lifecycle-protocol";
-import type { ConversationExtensions } from "../../features/conversation/contracts";
import type { ChannelSummary } from "../../features/relay/contracts";
import { communityFromScope } from "../../features/relay/gifs";
import {
@@ -57,9 +60,9 @@ const ChannelIcon = channelIcon({ channelType: "stream" });
/**
* The default message is never stored: a draft matching it restores it. The
- * default title is this viewer's name for the community, so it is never
- * inferred: an untouched title resends what is published, Restore defaults
- * (`null`) sends the default, and an edited title is sent as written.
+ * default title depends on the viewer, so it is never inferred: an untouched
+ * title resends what is published, Restore defaults (`null`) sends the
+ * default, and an edited title is sent as written.
*/
function commandFor(
draft: Readonly<{ text: string; title?: string | null }>,
@@ -89,13 +92,16 @@ function saveError(error: unknown) {
return message(error);
}
+const bidi = "Remove the hidden text-direction characters.";
const problems = {
empty: "Enter a welcome message, or restore the defaults.",
+ bidi,
long: `Shorten the message to ${MAX} characters.`,
control: "Remove control characters. Line breaks and tabs are fine.",
};
const titleProblems = {
empty: "Enter a title, or restore the defaults.",
+ bidi,
long: `Shorten the title to ${TITLE_MAX} characters.`,
control: "Use one line of text for the title.",
};
@@ -104,15 +110,13 @@ const titleProblems = {
* which other channels they join. */
export function WelcomeSettings({
relay,
- communityName,
+ communities,
active,
- extensions,
}: {
relay: RelayData;
- communityName?: string | undefined;
+ /** Names the community in the default title. */
+ communities?: CommunityReader | undefined;
active(): boolean;
- /** The conversation renderers, so the preview draws links as the card does. */
- extensions?: ConversationExtensions | undefined;
}) {
const connection = useRelayConnection(relay);
const community =
@@ -132,9 +136,8 @@ export function WelcomeSettings({
session={connection.session}
scope={connection.scope}
community={community}
- communityName={communityName}
+ communities={communities}
active={active}
- extensions={extensions}
/>
) : (
Choose a connected community to set its welcome.
@@ -159,6 +162,8 @@ type Load =
type Option = Readonly<{
id: string;
name: string;
+ /** Published: a joined channel's follows the roster, another's the
+ * directory, and both take a confirmed change at once. */
autoJoin: boolean;
/** In the viewer's roster, which follows the relay's metadata. */
joined: boolean;
@@ -169,6 +174,11 @@ type Draft = Readonly<{
text: string;
title?: string | null;
}>;
+/** One starter flag Save sets. */
+type StarterStep = Readonly<{ id: string; name: string; enabled: boolean }>;
+/** Drafted starter flags by channel; Save sets each that differs from the
+ * published one. */
+type StarterDraft = ReadonlyMap;
/** Browsing with no search lists this many channels by name; a search covers
* every channel, so a community with thousands still opens a short list. */
@@ -241,41 +251,68 @@ function eligibleChannels(
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
}
+/** The Check again button leaves with its notice; focus stays in the form,
+ * on the first of `targets` shown. */
+function stayInForm(
+ check: HTMLElement | null,
+ ...targets: readonly (HTMLElement | null)[]
+) {
+ if (document.activeElement === check)
+ targets.find(Boolean)?.focus({ preventScroll: true });
+}
+
+const without = (map: ReadonlyMap, id: string) => {
+ if (!map.has(id)) return map;
+ const next = new Map(map);
+ next.delete(id);
+ return next;
+};
+
function WelcomeEditor({
session,
scope,
community,
- communityName,
+ communities,
active,
- extensions,
}: {
session: RelaySession;
scope: string;
community: string;
- communityName?: string | undefined;
+ communities: CommunityReader | undefined;
active(): boolean;
- extensions: ConversationExtensions | undefined;
}) {
const lifecycle = session.channelLifecycle;
- const defaultTitle = defaultWelcomeTitle(communityName);
+ const defaultTitle = useDefaultWelcomeTitle(communities, scope);
+ const formId = useId();
const [attempt, setAttempt] = useState(0);
const [load, setLoad] = useState({ status: "loading" });
const [draft, setDraft] = useState();
+ const [starterDraft, setStarterDraft] = useState(
+ () => new Map(),
+ );
const [typed, setTyped] = useState("");
const [saving, setSaving] = useState(false);
- // The starter channel whose flag is being changed. The session runs one
- // channel command at a time, so Save waits for it and it waits for Save.
- const [toggling, setToggling] = useState();
const [error, setError] = useState("");
- // An unconfirmed publication: only a fresh read, never a repeat, settles it.
- const [unconfirmed, setUnconfirmed] = useState(false);
+ // A failed starter step's reason: a removal's beside its row, an addition's
+ // on the add control, since its row is gone.
+ const [starterErrors, setStarterErrors] = useState<
+ ReadonlyMap
+ >(() => new Map());
+ const [addError, setAddError] =
+ useState>();
+ // An unconfirmed publication, of the welcome or of one starter flag: only a
+ // fresh read, never a repeat, settles it.
+ const [unconfirmed, setUnconfirmed] = useState<"welcome" | StarterStep>();
const [checking, setChecking] = useState(false);
const [saved, setSaved] = useState(false);
// The check compares the relay's answer with the draft as it stands then.
const draftRef = useRef(draft);
draftRef.current = draft;
+ // The starter steps Save would run, for a check deciding whether all landed.
+ const pendingSteps = useRef([]);
const checkButton = useRef(null);
const messageInput = useRef(null);
+ const addInput = useRef(null);
const mounted = useRef(true);
const saveController = useRef(undefined);
useEffect(() => {
@@ -328,13 +365,17 @@ function WelcomeEditor({
setLoad({ status: "ready", published });
if (landed) {
setDraft(undefined);
- setSaved(true);
+ // Starter changes still wait for Save.
+ if (!pendingSteps.current.length) setSaved(true);
}
- setUnconfirmed(false);
+ setUnconfirmed(undefined);
setChecking(false);
- // The Check again button leaves with its notice; stay in the form.
- if (check && document.activeElement === checkButton.current)
- messageInput.current?.focus({ preventScroll: true });
+ if (check)
+ stayInForm(
+ checkButton.current,
+ messageInput.current,
+ addInput.current,
+ );
} catch (reason) {
if (reading.signal.aborted) return;
setChecking(false);
@@ -344,7 +385,8 @@ function WelcomeEditor({
return () => reading.abort();
}, [community, lifecycle, attempt]);
const published = load.status === "ready" ? load.published : undefined;
- const listing = load.status === "ready" || load.status === "unsupported";
+ const supported = load.status !== "unsupported";
+ const listing = load.status === "ready" || !supported;
const directory = useChannelDirectory(session.channels, listing);
const roster = useChannelRoster(session.channels);
const options = useMemo(
@@ -373,11 +415,31 @@ function WelcomeEditor({
typeof titleDraft === "string"
? welcomeTitleProblem(trimmedTitle)
: undefined;
- const dirty =
+ const welcomeDirty =
!!published &&
(channelId !== published.channelId ||
command.message !== published.welcome ||
command.title !== published.title);
+ // The welcome channel's own flag is left as it is. A drafted flag the relay
+ // already shows, as after a check, needs no step.
+ const welcomeChannel = published?.channelId
+ ? options.find((option) => option.id === published.channelId)
+ : undefined;
+ const steps = useMemo(
+ () =>
+ options.flatMap((option): StarterStep[] => {
+ const enabled = starterDraft.get(option.id);
+ return enabled === undefined ||
+ enabled === option.autoJoin ||
+ option.id === welcomeChannel?.id
+ ? []
+ : [{ id: option.id, name: option.name, enabled }];
+ }),
+ [options, starterDraft, welcomeChannel?.id],
+ );
+ pendingSteps.current = steps;
+ const dirty = welcomeDirty || steps.length > 0;
+ const invalid = welcomeDirty && (!channelId || !!problem || !!titleProblem);
const selected = options.find((option) => option.id === channelId) ?? null;
// The input shows the chosen name; browsing from there lists every channel.
const needle = typed === selected?.name ? "" : typed.trim().toLowerCase();
@@ -392,23 +454,210 @@ function WelcomeEditor({
setSaved(false);
setError("");
};
+ const editStarter = (channel: Option, enabled: boolean) => {
+ setStarterDraft((current) =>
+ enabled === channel.autoJoin
+ ? without(current, channel.id)
+ : new Map(current).set(channel.id, enabled),
+ );
+ setStarterErrors((current) => without(current, channel.id));
+ if (enabled || addError?.id === channel.id) setAddError(undefined);
+ setSaved(false);
+ setError("");
+ };
+ const isActive = () => mounted.current && active();
+ // The welcome first, then each starter flag in turn. A confirmed step is
+ // published; a refused one returns to its published state, where its reason
+ // shows; the steps after it stay in the draft.
+ const save = () => {
+ if (saving || !dirty || invalid || unconfirmed || !lifecycle.available)
+ return;
+ const welcome =
+ welcomeDirty && channelId
+ ? {
+ target: channelId,
+ sent: command,
+ name: options.find((option) => option.id === channelId)?.name,
+ }
+ : undefined;
+ const pending = steps;
+ const owner = new AbortController();
+ saveController.current = owner;
+ setSaving(true);
+ setSaved(false);
+ setError("");
+ setStarterErrors(new Map());
+ setAddError(undefined);
+ void (async () => {
+ let step: StarterStep | undefined;
+ try {
+ if (welcome) {
+ await lifecycle.setLanding(
+ welcome.target,
+ welcome.sent,
+ owner.signal,
+ isActive,
+ );
+ if (!mounted.current) return;
+ setLoad({
+ status: "ready",
+ published: {
+ channelId: welcome.target,
+ ...(welcome.name ? { name: welcome.name } : {}),
+ welcome: welcome.sent.message,
+ title: welcome.sent.title,
+ },
+ });
+ setDraft(undefined);
+ }
+ for (const next of pending) {
+ step = next;
+ await lifecycle.run(
+ next.enabled ? "auto-join-on" : "auto-join-off",
+ next.id,
+ owner.signal,
+ isActive,
+ );
+ if (!mounted.current) return;
+ // The confirmed readback already updated the published flag.
+ setStarterDraft((current) => without(current, next.id));
+ }
+ setSaved(true);
+ } catch (reason) {
+ if (!mounted.current || owner.signal.aborted) return;
+ if (reason instanceof ChannelLifecycleUnconfirmed)
+ setUnconfirmed(step ?? "welcome");
+ else if (!step) setError(saveError(reason));
+ else {
+ const { id, enabled } = step;
+ setStarterDraft((current) => without(current, id));
+ if (enabled) setAddError({ id, message: saveError(reason) });
+ else setStarterErrors(new Map([[id, saveError(reason)]]));
+ }
+ } finally {
+ if (mounted.current) setSaving(false);
+ }
+ })();
+ };
+ // A starter step is checked by a strong read of its channel, which also
+ // updates the published flag; the welcome, by reading the card again.
+ const check = () => {
+ setChecking(true);
+ const step = unconfirmed;
+ if (typeof step !== "object") {
+ setAttempt((n) => n + 1);
+ return;
+ }
+ const owner = new AbortController();
+ saveController.current = owner;
+ setError("");
+ void (async () => {
+ try {
+ const state = await lifecycle.readLanding(step.id, owner.signal);
+ if (!mounted.current) return;
+ if (!!state?.autoJoin === step.enabled) {
+ setStarterDraft((current) => without(current, step.id));
+ if (
+ !draftRef.current &&
+ !pendingSteps.current.some(({ id }) => id !== step.id)
+ )
+ setSaved(true);
+ }
+ setUnconfirmed(undefined);
+ stayInForm(checkButton.current, messageInput.current, addInput.current);
+ } catch (reason) {
+ if (!mounted.current || owner.signal.aborted) return;
+ setError(message(reason));
+ } finally {
+ if (mounted.current) setChecking(false);
+ }
+ })();
+ };
const disabled = saving || !published || !lifecycle.available;
const starters = (
option.id === published.channelId)
- : undefined
- }
- lifecycle={lifecycle}
- active={() => mounted.current && active()}
+ welcomeChannel={welcomeChannel}
+ draft={starterDraft}
+ onChange={editStarter}
+ available={lifecycle.available}
saving={saving}
- onPendingChange={setToggling}
+ errors={starterErrors}
+ addError={addError}
+ addInput={addInput}
loading={directory.loading}
failed={!!directory.error}
// The welcome form offers this retry when it is shown.
- retry={load.status === "unsupported" ? directory.retry : undefined}
+ retry={supported ? undefined : directory.retry}
+ />
+ );
+ const actions = (draft !== undefined || steps.length > 0) && (
+
+ {
+ setDraft(undefined);
+ setStarterDraft(new Map());
+ setError("");
+ setStarterErrors(new Map());
+ setAddError(undefined);
+ }}
+ >
+ Cancel
+
+
+ Save
+
+
+ );
+ const footer = (error || unconfirmed || actions) && (
+
+ {error && (
+
+ {error}
+
+ )}
+ {unconfirmed && (
+
+
+ Not confirmed yet. The relay hasn’t shown{" "}
+ {unconfirmed === "welcome"
+ ? "this change"
+ : `the change to #${unconfirmed.name}`}
+ ; check before saving again.
+
+
+
+ Check again
+
+
+
+ )}
+ {actions}
+
+ );
+ const toast = saved && (
+ setSaved(false)}
/>
);
return (
@@ -432,8 +681,12 @@ function WelcomeEditor({
) : undefined
}
>
- {load.status === "unsupported" ? (
- starters
+ {!supported ? (
+
+ {starters}
+ {footer}
+ {toast}
+
) : (
{
event.preventDefault();
- if (
- disabled ||
- !dirty ||
- !channelId ||
- problem ||
- titleProblem ||
- toggling
- )
- return;
- const target = channelId;
- const sent = command;
- const name = options.find(
- (option) => option.id === target,
- )?.name;
- const owner = new AbortController();
- saveController.current = owner;
- setSaving(true);
- setSaved(false);
- setError("");
- void (async () => {
- try {
- await lifecycle.setLanding(
- target,
- sent,
- owner.signal,
- () => mounted.current && active(),
- );
- if (!mounted.current) return;
- setLoad({
- status: "ready",
- published: {
- channelId: target,
- ...(name ? { name } : {}),
- welcome: sent.message,
- title: sent.title,
- },
- });
- setDraft(undefined);
- setSaved(true);
- } catch (reason) {
- if (!mounted.current || owner.signal.aborted) return;
- if (reason instanceof ChannelLifecycleUnconfirmed)
- setUnconfirmed(true);
- else setError(saveError(reason));
- } finally {
- if (mounted.current) setSaving(false);
- }
- })();
+ if (!disabled) save();
}}
>
@@ -605,181 +812,67 @@ function WelcomeEditor({
)}
- {error && (
-
- {error}
-
- )}
- {unconfirmed && (
-
-
- Not confirmed yet. The relay hasn’t shown this change; check
- before saving again.
-
-
- {
- setChecking(true);
- setAttempt((n) => n + 1);
- }}
- >
- Check again
-
-
-
- )}
- {draft && (
-
- {
- setDraft(undefined);
- setError("");
- }}
- >
- Cancel
-
-
- Save
-
-
- )}
- {saved && (
- setSaved(false)}
- />
- )}
-
-
-
- false}
- interactive={false}
- />
-
-
{starters}
+ {footer}
+ {toast}
)}
);
}
-/** A starter's flag as this card knows it: a joined channel's follows the
- * roster; another's, the change confirmed here until the directory shows it. */
-const starter = (channel: Option, confirmed: ReadonlyMap) =>
- channel.joined
- ? channel.autoJoin
- : (confirmed.get(channel.id) ?? channel.autoJoin);
-
/**
- * Connected objects: the channels whose relay `auto_join` flag is on, each
- * removable at once, and a search that adds another. Only flagged channels are
- * listed, so the list stays short however many channels the community has.
- * Each change shows once a signed readback confirms it. Community owners and
- * admins may also flag channels they have not joined. The welcome channel is
- * always joined first, so its row is fixed and its own flag is left as it is.
+ * Connected objects: the channels whose relay `auto_join` flag is on, and a
+ * search that adds another. Only starter channels are listed, so the list
+ * stays short however many channels the community has. Add and Remove edit
+ * the draft; Save publishes them. A drafted addition is listed as not saved
+ * yet, and a drafted removal leaves the list until Cancel. Community owners
+ * and admins may also flag channels they have not joined. The welcome channel
+ * is always joined first, so its row is fixed and its own flag is left as it
+ * is.
*/
function StarterChannels({
channels,
welcomeChannel,
- lifecycle,
- active,
+ draft,
+ onChange,
+ available,
saving,
- onPendingChange,
+ errors,
+ addError,
+ addInput,
loading,
failed,
retry,
}: {
channels: readonly Option[];
welcomeChannel?: Option | undefined;
- lifecycle: ChannelLifecycleCapability;
- active(): boolean;
+ draft: StarterDraft;
+ onChange(channel: Option, enabled: boolean): void;
+ available: boolean;
saving: boolean;
- onPendingChange(id: string | undefined): void;
+ errors: ReadonlyMap;
+ addError?: Readonly<{ id: string; message: string }> | undefined;
+ addInput: RefObject;
loading: boolean;
failed: boolean;
/** Offered here only when nothing else on the card offers it. */
retry?: (() => void) | undefined;
}) {
const heading = useId();
- const addInput = useRef(null);
const [typed, setTyped] = useState("");
- // The change the relay is confirming. The session runs one channel command
- // at a time, so Save waits for it and it waits for Save.
- const [change, setChange] =
- useState>();
- const [confirmed, setConfirmed] = useState>(
- () => new Map(),
- );
- // Failed removals, beside their rows; a failed addition has no row, so its
- // reason shows on the add control.
- const [errors, setErrors] = useState>(
- () => new Map(),
- );
- const [addError, setAddError] =
- useState>();
- // The last confirmed change, announced until the next one starts.
- const [notice, setNotice] = useState();
- const operation = useRef(undefined);
- useEffect(() => () => operation.current?.abort(), []);
- // A kept state is dropped once the row agrees, or turns out to be joined.
- useEffect(() => {
- setConfirmed((current) => {
- let next: Map | undefined;
- for (const [id, enabled] of current) {
- const row = channels.find((channel) => channel.id === id);
- if (row && (row.joined || row.autoJoin === enabled)) {
- next ??= new Map(current);
- next.delete(id);
- }
- }
- return next ?? current;
- });
- }, [channels]);
- // A channel being added shows while the relay confirms it, as one being
- // removed stays until it has.
const { starters, addable } = useMemo(() => {
const starters: Option[] = [];
const addable: Option[] = [];
for (const channel of channels)
if (channel.id !== welcomeChannel?.id)
- (starter(channel, confirmed) || channel.id === change?.id
- ? starters
- : addable
- ).push(channel);
+ ((draft.get(channel.id) ?? channel.autoJoin) ? starters : addable).push(
+ channel,
+ );
return { starters, addable };
- }, [channels, welcomeChannel?.id, confirmed, change?.id]);
+ }, [channels, welcomeChannel?.id, draft]);
const needle = typed.trim().toLowerCase();
const matches = useMemo(() => ranked(addable, needle), [addable, needle]);
const capped = !needle && addable.length > BROWSE_LIMIT;
@@ -787,52 +880,7 @@ function StarterChannels({
addError && !starters.some((channel) => channel.id === addError.id)
? `#${channels.find((channel) => channel.id === addError.id)?.name ?? addError.id.slice(0, 8)}: ${addError.message}`
: undefined;
- const busy = saving || change !== undefined || !lifecycle.available;
- const toggle = async (
- { id, name }: Option,
- enabled: boolean,
- trigger?: HTMLElement,
- ) => {
- if (operation.current || saving) return;
- const controller = new AbortController();
- operation.current = controller;
- setChange({ id, enabled });
- onPendingChange(id);
- setNotice(undefined);
- if (enabled) setAddError(undefined);
- setErrors((current) => {
- const next = new Map(current);
- next.delete(id);
- return next;
- });
- try {
- await lifecycle.run(
- enabled ? "auto-join-on" : "auto-join-off",
- id,
- controller.signal,
- active,
- );
- // The removed row leaves with its button; focus stays in the list.
- if (trigger && document.activeElement === trigger)
- addInput.current?.focus({ preventScroll: true });
- setConfirmed((current) => new Map(current).set(id, enabled));
- setNotice(`${enabled ? "Added" : "Removed"} #${name}`);
- } catch (reason) {
- if (controller.signal.aborted) return;
- // Setting the same value again is idempotent, so retrying is the check.
- const message =
- reason instanceof ChannelLifecycleUnconfirmed
- ? "Saved, but not confirmed yet. Try again to check."
- : saveError(reason);
- if (enabled) setAddError({ id, message });
- else setErrors((current) => new Map(current).set(id, message));
- } finally {
- operation.current = undefined;
- setChange(undefined);
- // The form may outlive this list, as when a check finds no support.
- onPendingChange(undefined);
- }
- };
+ const busy = saving || !available;
const empty = starters.length
? undefined
: loading
@@ -843,11 +891,11 @@ function StarterChannels({
? "No other channels added yet"
: "No channels added yet";
return (
-
+
{(welcomeChannel || starters.length > 0 || empty) && (
@@ -859,24 +907,25 @@ function StarterChannels({
/>
)}
{starters.map((channel) => {
- const adding = change?.id === channel.id && change.enabled;
- const removing = change?.id === channel.id && !change.enabled;
const error = errors.get(channel.id);
return (
}
title={channel.name}
- subtitle={adding ? "Adding…" : undefined}
+ subtitle={
+ channel.autoJoin ? undefined : "Added · not saved yet"
+ }
trailing={
- void toggle(channel, false, event.currentTarget)
- }
+ onClick={() => {
+ onChange(channel, false);
+ // The row leaves with its button; focus stays in the list.
+ addInput.current?.focus({ preventScroll: true });
+ }}
>
Remove
@@ -910,12 +959,12 @@ function StarterChannels({
onValueChange={(option) => {
if (!option) return;
setTyped("");
- void toggle(option, true);
+ onChange(option, true);
}}
itemToStringLabel={(option) => option.name}
itemToStringValue={(option) => option.id}
isItemEqualToValue={(a, b) => a.id === b.id}
- disabled={!lifecycle.available}
+ disabled={!available}
autoHighlight
>
)}
- {notice && (
- setNotice(undefined)}
- />
- )}
);
}
diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx
index 959388ccd7..b6a55deac1 100644
--- a/src/bundled/moderation/index.tsx
+++ b/src/bundled/moderation/index.tsx
@@ -8,7 +8,7 @@ export const inject = [
"settingsCards",
"adminAccess",
"navigation",
- "conversation",
+ "communityReader",
];
export const apply: PluginModule["apply"] = (ctx) => {
const relay = ctx.relay;
@@ -79,12 +79,11 @@ export const apply: PluginModule["apply"] = (ctx) => {
subscribe: membership.subscribe,
ensure: membership.ensure,
},
- component: ({ active, community }) => (
+ component: ({ active }) => (
),
});
diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts
index 6012160fcb..b5a92ceed3 100644
--- a/src/bundled/moderation/registration.test.ts
+++ b/src/bundled/moderation/registration.test.ts
@@ -64,8 +64,11 @@ it("registers the Membership card under the key the community rail opens", async
});
const data = relay();
root.provide("relay", data);
- // The Welcome card's preview renders through the conversation renderers.
- root.provide("conversation", {} as never);
+ // The Welcome card names the community in its default title.
+ root.provide("communityReader", {
+ snapshot: () => ({ memberships: [] }),
+ subscribe: () => () => {},
+ } as unknown as CommunityReader);
const access = createAdminAccess(
data,
{
@@ -125,7 +128,10 @@ it.each([
});
const data = relay(role);
root.provide("relay", data);
- root.provide("conversation", {} as never);
+ root.provide("communityReader", {
+ snapshot: () => ({ memberships: [] }),
+ subscribe: () => () => {},
+ } as unknown as CommunityReader);
const access = createAdminAccess(
data,
{
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
index ba9d6a3673..a5abcc7561 100644
--- a/src/features/communities/WelcomeCard.test.tsx
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -76,7 +76,7 @@ it("opens an identity link's profile from the timeline without notifying", async
expect(onOpenLink).toHaveBeenCalledWith(target);
});
-it("shows an identity link as an inert chip in a preview", () => {
+it("shows an identity link as an inert chip when not interactive, as the editor's chips do", () => {
const target = profileTarget(person);
const view = render(
welcome(`Ask [@Ana](${target}) anything.`, { interactive: false }),
@@ -120,3 +120,46 @@ it("titles the default with the community the viewer saved, else a custom title"
);
expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
});
+
+it("welcomes a community known only by its address without naming it", () => {
+ const reader = (membership: Record) =>
+ ({
+ snapshot: () => ({
+ memberships: [{ id: "https://primary.example", ...membership }],
+ }),
+ subscribe: () => () => {},
+ }) as unknown as CommunityReader;
+ const { rerender } = render(
+
+ Body
+ ,
+ );
+ // Joined without a published name, the membership holds only the host.
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ rerender(
+
+ Body
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Welcome to Primary" }),
+ ).toBeVisible();
+ rerender(
+
+ Body
+ ,
+ );
+ expect(screen.getByRole("region", { name: "Welcome to Home" })).toBeVisible();
+});
diff --git a/src/features/communities/WelcomeCard.tsx b/src/features/communities/WelcomeCard.tsx
index 088288074c..8931fbc3a8 100644
--- a/src/features/communities/WelcomeCard.tsx
+++ b/src/features/communities/WelcomeCard.tsx
@@ -5,6 +5,7 @@ import { useReferenceDirectory } from "../messages/ReferenceText";
import { splitPartition } from "../relay/partition";
import type { RelaySession } from "../relay/session";
import { communityPresentation } from "./community-icon";
+import { communityDestination } from "./destination";
import type { CommunityReader } from "./service";
import styles from "./WelcomeCard.module.css";
@@ -17,6 +18,34 @@ export function defaultWelcomeTitle(communityName?: string | undefined) {
return communityName ? `Welcome to ${communityName}` : "Welcome";
}
+const noCommunities = () => () => {};
+const noSnapshot = () => undefined;
+
+/** The default title for the community of the relay session `scope`. Only a
+ * configured name counts, this viewer's own or the relay's published one; a
+ * community known only by its address is welcomed without a name. */
+export function useDefaultWelcomeTitle(
+ communities: CommunityReader | undefined,
+ scope: string,
+) {
+ const snapshot = useSyncExternalStore(
+ communities?.subscribe ?? noCommunities,
+ communities?.snapshot ?? noSnapshot,
+ );
+ const origin = splitPartition(scope)?.communityOrigin;
+ let name: string | undefined;
+ if (origin && origin !== "offline")
+ try {
+ const presented = communityPresentation(origin, snapshot?.memberships);
+ // Without a configured name, the presentation falls back to the host.
+ if (presented.name !== communityDestination(origin).name)
+ name = presented.name;
+ } catch {
+ // An unscoped reader has no community presentation to show.
+ }
+ return defaultWelcomeTitle(name);
+}
+
/** The welcome channel's introduction; a timeline start, never a message. */
export function WelcomeCard({
title,
@@ -44,7 +73,8 @@ export function WelcomeCard({
* The welcome message: Markdown source rendered as a message body is, with
* its `buzz://` channel and message links and `nostr:npub` identity links.
* Identity links name people without notifying them. A non-interactive
- * preview shows the same references without their actions.
+ * render, such as the editor's link chips, shows the same references without
+ * their actions.
*/
export function WelcomeMessage({
message,
@@ -94,11 +124,8 @@ export function WelcomeMessage({
);
}
-const noCommunities = () => () => {};
-const noSnapshot = () => undefined;
-
-/** The welcome card, titled by default with the name this viewer knows the
- * community by. */
+/** The welcome card, titled by default with the community's configured
+ * name (see {@link useDefaultWelcomeTitle}). */
export function CommunityWelcomeCard({
communities,
scope,
@@ -112,21 +139,6 @@ export function CommunityWelcomeCard({
title?: string | undefined;
children: ReactNode;
}) {
- const snapshot = useSyncExternalStore(
- communities?.subscribe ?? noCommunities,
- communities?.snapshot ?? noSnapshot,
- );
- const origin = splitPartition(scope)?.communityOrigin;
- let name: string | undefined;
- if (origin && origin !== "offline")
- try {
- name = communityPresentation(origin, snapshot?.memberships).name;
- } catch {
- // An unscoped reader has no community presentation to show.
- }
- return (
-
- {children}
-
- );
+ const defaultTitle = useDefaultWelcomeTitle(communities, scope);
+ return {children} ;
}
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
index b1f18a8b52..f0b0211047 100644
--- a/src/features/relay/landing.test.ts
+++ b/src/features/relay/landing.test.ts
@@ -72,13 +72,17 @@ describe("welcome text", () => {
expect(welcomeTextProblem("😀".repeat(4000))).toBeUndefined();
expect(welcomeTextProblem("a".repeat(4001))).toBe("long");
expect(welcomeTextProblem(" \n ")).toBe("empty");
- // U+FEFF is not Unicode White_Space: the relay keeps it, so both hosts do.
- expect(welcomeTextProblem("\uFEFF")).toBeUndefined();
expect(welcomeTextProblem("\u2003\u3000")).toBe("empty");
// Markdown needs line breaks and tabs for structure and code.
expect(welcomeTextProblem("line\nbreak")).toBeUndefined();
expect(welcomeTextProblem("- item\n\t- nested")).toBeUndefined();
- for (const text of ["cr\r", "nul\u0000", "vt\u000b", "del\u007f", "c1\u0085"])
+ for (const text of [
+ "cr\r",
+ "nul\u0000",
+ "vt\u000b",
+ "del\u007f",
+ "c1\u0085",
+ ])
expect(welcomeTextProblem(text)).toBe("control");
});
@@ -95,7 +99,6 @@ describe("welcome title", () => {
expect(welcomeTitleProblem("😀".repeat(120))).toBeUndefined();
expect(welcomeTitleProblem("a".repeat(121))).toBe("long");
expect(welcomeTitleProblem(" ")).toBe("empty");
- expect(welcomeTitleProblem("\uFEFF")).toBeUndefined();
for (const text of [
"two\nlines",
"tab\t",
@@ -117,6 +120,54 @@ describe("welcome title", () => {
});
});
+describe("welcome blank and direction rules", () => {
+ it("counts text of only whitespace and invisible characters as blank, as the relay does", () => {
+ for (const text of [
+ "\u200B",
+ "\u2800",
+ " \u200B ",
+ "\uFEFF",
+ "\u00AD\u034F\u115F\u1160\u3164\uFFA0",
+ "\u180B\u2060\u206F\uFE00\uFE0F\uFFF9",
+ "\u{1D173}\u{E0000}\u{E007F}",
+ ]) {
+ expect(welcomeTextProblem(text)).toBe("empty");
+ expect(welcomeTitleProblem(text)).toBe("empty");
+ }
+ // Blank is checked first, so only direction marks are still empty.
+ expect(welcomeTextProblem("\u200E\u200F")).toBe("empty");
+ // Invisible characters beside visible text are kept.
+ expect(welcomeTextProblem("Hello\u200Bworld")).toBeUndefined();
+ expect(welcomeTitleProblem("Hello\u200Bworld")).toBeUndefined();
+ });
+
+ it("refuses text-direction characters anywhere, before the length", () => {
+ for (const mark of [
+ "\u061C",
+ "\u200E",
+ "\u200F",
+ "\u202A",
+ "\u202E",
+ "\u2066",
+ "\u2069",
+ ]) {
+ expect(welcomeTextProblem(`${mark}abc`)).toBe("bidi");
+ expect(welcomeTitleProblem(`a${mark}bc`)).toBe("bidi");
+ }
+ expect(welcomeTextProblem(`\u202E${"a".repeat(4001)}`)).toBe("bidi");
+ expect(validWelcomeCommand("\u202Eabc")).toBe(false);
+ expect(validWelcomeTitleCommand("\u202Eabc")).toBe(false);
+ // A published value the relay would refuse shows the default.
+ expect(
+ landingTags([
+ ["landing"],
+ ["welcome", "\u202Eabc"],
+ ["welcome_title", "\u200B"],
+ ]),
+ ).toEqual({ landing: true });
+ });
+});
+
describe("landingInfo", () => {
const id = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11";
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
index 05f84e2774..d6a85b264a 100644
--- a/src/features/relay/landing.ts
+++ b/src/features/relay/landing.ts
@@ -42,18 +42,27 @@ export function normalizeWelcome(text: string) {
return text.replace(/\r\n?/g, "\n").trim();
}
-// Unicode White_Space, as the relay's `str::trim` sees it: unlike `trim()`
-// it keeps U+FEFF, so a value of only that passes both hosts alike.
-const blank = (text: string) => /^\p{White_Space}*$/u.test(text);
+// The relay's blank rule: every code point is Unicode White_Space or one of
+// these invisible ones (soft hyphen, fillers, joiners and direction marks,
+// variation selectors, Braille blank, BOM, tags and the like).
+const blank = (text: string) =>
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: each listed code point is matched on its own, joiners and selectors included.
+ /^[\p{White_Space}\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2800\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF9-\uFFFB\u{1D173}-\u{1D17A}\u{E0000}-\u{E007F}]*$/u.test(
+ text,
+ );
+// Bidirectional marks, embeddings, overrides and isolates, refused anywhere.
+const bidi = (text: string) =>
+ /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u.test(text);
/** Mirrors the relay's rules for a normalized, non-default message: Markdown
* source whose only control characters are line breaks and tabs. */
export function welcomeTextProblem(
text: string,
-): "empty" | "long" | "control" | undefined {
+): "empty" | "bidi" | "long" | "control" | undefined {
if (blank(text)) return "empty";
+ if (bidi(text)) return "bidi";
if ([...text].length > WELCOME_MAX_LENGTH) return "long";
- // eslint-disable-next-line no-control-regex
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the relay refuses these control characters.
if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(text)) return "control";
return undefined;
}
@@ -62,10 +71,11 @@ export function welcomeTextProblem(
* no control characters at all, nor the Unicode line and paragraph separators. */
export function welcomeTitleProblem(
text: string,
-): "empty" | "long" | "control" | undefined {
+): "empty" | "bidi" | "long" | "control" | undefined {
if (blank(text)) return "empty";
+ if (bidi(text)) return "bidi";
if ([...text].length > WELCOME_TITLE_MAX_LENGTH) return "long";
- // eslint-disable-next-line no-control-regex
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the relay refuses these control characters.
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) return "control";
return undefined;
}
@@ -80,11 +90,14 @@ export function validWelcomeTitleCommand(text: string) {
return text === "" || !welcomeTitleProblem(text);
}
-/** Relay support and its currently designated channel, from NIP-11. */
-export function landingInfo(info: unknown): {
+/** Relay support for a welcome channel and its current designation. */
+export type LandingInfo = Readonly<{
landingChannels: boolean;
landingChannel?: string;
-} {
+}>;
+
+/** Relay support and its currently designated channel, from NIP-11. */
+export function landingInfo(info: unknown): LandingInfo {
const record =
info && typeof info === "object"
? (info as Record)
From 166f6100c69d0934b50a958f43d3ceefd560dd45 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 21:08:34 +1100
Subject: [PATCH 14/28] fix(channels): wait for the relay's welcome channel
before opening another
With nothing saved or requested, Messages reads the relay's NIP-11
landing info. While that read is pending, or while the welcome channel it
names is not listed yet, no DM or other fallback opens; the opening state
holds until the channel arrives or ENROLLMENT_WAIT_MS has passed since the
list first loaded. A relay without welcome channels skips the empty-list
wait on the first list.
A welcome channel that arrives after the visit already opened (the waits
complete it), or the fallback chosen once a held opening gives up, now
replaces that visit with replace: true and saves the selection, instead
of showing without a route or a saved selection.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 6 +-
docs/channels.md | 18 +-
docs/communities.md | 3 +-
.../channels/ChannelsPage.tabs.test.tsx | 165 ++++++++++++++++--
src/bundled/channels/ChannelsPage.tsx | 133 ++++++++++++--
src/bundled/channels/index.tsx | 14 +-
6 files changed, 304 insertions(+), 35 deletions(-)
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 800fc5bda6..00e255a3f0 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -466,8 +466,10 @@ revokes all of them, and a pairing unused for 30 days expires.
public channel that new members join and open first. It starts with a
welcome card for everyone, showing the community's message or a default one.
The card isn't a message: it can't be replied to, deleted or dismissed. While
- a new member's channels arrive, Messages says it is opening them. If none
- arrive within a few seconds, **Try again** checks for them again. Owners and admins choose the channel and edit
+ a new member's channels arrive, Messages says it is opening them, and waits a
+ few seconds for the welcome channel rather than open another conversation
+ first. If none arrive, **Try again** checks for them again; a welcome channel
+ that arrives later still opens. Owners and admins choose the channel and edit
the card's title and message in **Settings → Community admin → Welcome**. The
message editor has the composer's formatting toolbar and shortcuts. The
default title names the community when the relay or you have given it a
diff --git a/docs/channels.md b/docs/channels.md
index e03e654e9f..2db038beba 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1307,7 +1307,12 @@ channel for new members), then the first non-Solo channel; an explicit empty
route bypasses all three. The welcome channel is the stream channel whose
relay-signed `39000` carries `landing`. Until every listed channel's metadata
has arrived, any of them could be the welcome channel, so the starter and
-first-channel fallbacks wait. The relay's membership hints can still add a new
+first-channel fallbacks wait. They also wait for the relay's NIP-11 document,
+read when Messages has nothing saved or requested to open, and while the
+welcome channel it names is not listed yet: no DM or other channel opens in its
+place until it arrives or ten seconds pass from the channel list first loading.
+The channel opened when that wait ends is saved as the selection, so the next
+opening does not wait again. The relay's membership hints can still add a new
member to their starter channel before their welcome channel, so a fallback
opened while no welcome channel is listed is provisional: a welcome channel that
arrives within ten seconds of the channel list first loading replaces it, unless
@@ -1322,9 +1327,14 @@ still loading, Messages shows **Opening your channels…** instead of the blank
selection view. A list with no channel to open keeps that state for up to ten
seconds, once per community visit, since the relay can still be adding a new
member to the welcome channel; a channel that arrives meanwhile opens at once. A
-returning member with no channels left waits the same once. After that wait, an
-empty state offers **Try again**, which re-reads the channel list and waits the
-same way again. The client never joins the welcome channel for them. The relay
+returning member with no channels left waits the same once. A relay whose NIP-11
+document does not advertise `buzz-landing-channel` adds no one to a welcome
+channel, so its first empty list shows the empty state at once. After that
+wait, an empty state offers **Try again**, which re-reads the channel list and
+waits the same way again, on any relay. A welcome channel that arrives after
+Messages opened without one, even after the empty state, opens then and is
+saved as the selection; it replaces that opening in history, unless Settings or
+another visit is shown by then. The client never joins the welcome channel for them. The relay
adds people to its welcome and `auto_join` channels itself, welcome channel
first, and the resulting membership hints populate the sidebar live:
diff --git a/docs/communities.md b/docs/communities.md
index 6b56a578cb..9f8c8254bc 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -170,7 +170,8 @@ community origin and viewer; channel IDs alone are not sufficient keys.
A relay that advertises the `buzz-landing-channel` NIP-11 extension keeps one
welcome channel per community. The relay adds each newly entering human to it
first, ahead of the starter channels. Messages opens it when there is no saved
-selection, and its timeline starts with a welcome card (see
+selection, waiting briefly for a designated channel that is not listed yet, and
+its timeline starts with a welcome card (see
[local-first launch](channels.md#local-first-launch)).
Owners and admins set it under **Community admin → Welcome**, which appears
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 6694cac280..d2c585b800 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -38,13 +38,13 @@ import {
roster,
signed,
} from "../../features/relay/testing";
-import { writeView } from "../../shared/view-state";
+import { readView, writeView } from "../../shared/view-state";
import type { Navigation } from "../../features/navigation/controller";
import { useChannelTabState } from "./useChannelTabState";
import { setThreadPlacementPreference } from "../../features/messages/thread-preferences";
import { SOLO_LABEL } from "../../features/solo/labels";
import { composerDOMFixture } from "../../features/messages/composer-testing";
-import { ChannelsPage } from "./ChannelsPage";
+import { ChannelsPage, type LandingReader } from "./ChannelsPage";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { BrowserHostView } from "../../features/browser/BrowserHostView";
import { BrowserSessions } from "../../features/browser/sessions";
@@ -305,12 +305,14 @@ it.each(["side", "main"] as const)(
);
/** A page over `events` with no saved selection and no registered panels.
- * A read waits for the promise `hold` returns for its filters, if any. */
+ * A read waits for the promise `hold` returns for its filters, if any; the
+ * relay's welcome-channel support is read from `landing`, if given. */
function workspace(
events: RelayEvent[],
viewer = keypair(),
authority = keypair(),
hold?: (filters: readonly ReadFilter[]) => Promise | undefined,
+ landing?: LandingReader,
) {
const owner = createRelaySession(
{
@@ -354,9 +356,8 @@ function workspace(
requestOpen: () => false,
handleOpenRequests: () => () => {},
};
- const navigator = createNavigationController(
- createMemoryHistory(),
- ).navigation;
+ const controller = createNavigationController(createMemoryHistory());
+ const navigator = controller.navigation;
const page = (navigation?: PageNavigation) => (
);
const { rerender } = render(page());
- /** Re-route the mounted page; `undefined` returns to the unrouted workspace. */
- const visit = (target?: OpenTarget, entryId = "visit") =>
+ /** Re-route the mounted page; `undefined` returns to the unrouted workspace.
+ * A `hosted` visit answers the navigator's pending attempt, as the host's
+ * does, so once it has opened it resolves nothing. */
+ const visit = (target?: OpenTarget, entryId = "visit", hosted = false) =>
rerender(
page(
target && {
@@ -382,8 +386,13 @@ function workspace(
forSession() {
return this;
},
- resolve: () => true,
- complete: () => true,
+ resolve: hosted
+ ? (next) => controller.resolve(navigator.snapshot().attempt, next)
+ : () => true,
+ complete: hosted
+ ? (result) =>
+ controller.complete(navigator.snapshot().attempt, result)
+ : () => true,
},
),
);
@@ -647,6 +656,142 @@ it("opens the welcome channel as soon as it arrives while the opening state wait
).toBeNull();
});
+/** The Messages page, opened through the navigator's own visit. */
+const messagesPage: OpenTarget = {
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.channels",
+ pageId: "channels",
+};
+
+it("shows an empty list at once on a relay without welcome channels", async () => {
+ const landing = vi.fn(async () => ({
+ landingChannels: false,
+ }));
+ const { owner } = workspace([], keypair(), keypair(), undefined, landing);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ // No one is being added to a welcome channel, so there is nothing to wait for.
+ expect(
+ await screen.findByRole("heading", {
+ name: "You’re not in any channels yet",
+ }),
+ ).toBeVisible();
+ expect(screen.queryByText("Opening your channels…")).toBeNull();
+ expect(landing).toHaveBeenCalledWith("https://relay.test", expect.anything());
+});
+
+/** A new member listed only in a DM so far, on a relay whose NIP-11 names
+ * `beta` as its welcome channel. */
+async function awaitingWelcome(hosted = false) {
+ const viewer = keypair(),
+ authority = keypair(),
+ other = keypair();
+ const events: RelayEvent[] = [
+ roster(authority, "pat", [viewer.pubkey, other.pubkey]),
+ metadata(authority, "pat", "Pat", undefined, [["t", "dm"]]),
+ ];
+ const landing = vi.fn(async () => ({
+ landingChannels: true,
+ landingChannel: "beta",
+ }));
+ const { owner, scope, navigator, visit } = workspace(
+ events,
+ viewer,
+ authority,
+ undefined,
+ landing,
+ );
+ if (hosted) visit(messagesPage, navigator.snapshot().entry.id, true);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ await vi.waitFor(() => expect(landing).toHaveBeenCalledOnce());
+ // The read has answered and the page has rendered its outcome.
+ await act(async () => {});
+ return { owner, scope, navigator, viewer, authority, events };
+}
+
+it("waits for the welcome channel the relay names rather than open a DM, then opens it", async () => {
+ const { owner, viewer, authority, events } = await awaitingWelcome();
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ expect(screen.queryByRole("textbox")).toBeNull();
+ events.push(
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ );
+ owner.session.channels.refreshList?.();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+});
+
+it("opens and keeps the DM once the welcome channel the relay names stays unlisted", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ const { scope, navigator } = await awaitingWelcome(true);
+ // The visit opened on the wait; the DM then replaces it.
+ expect(navigator.snapshot().status).toBe("opened");
+ await act(() => vi.advanceTimersByTimeAsync(9_000));
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ expect(screen.queryByRole("textbox")).toBeNull();
+ await act(() => vi.advanceTimersByTimeAsync(1_000));
+ expect(await screen.findByRole("textbox")).toBeVisible();
+ expect(navigator.snapshot()).toMatchObject({
+ canGoBack: false,
+ entry: { target: { kind: "conversation", channelId: "pat" } },
+ });
+ // Saved, so the next opening shows it without waiting again.
+ expect(readView(scope, "selected-channel", undefined)).toBe("pat");
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+it("opens and saves a welcome channel that arrives after the empty list settled", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ const viewer = keypair(),
+ authority = keypair();
+ const events: RelayEvent[] = [];
+ const { owner, scope, navigator, visit } = workspace(
+ events,
+ viewer,
+ authority,
+ );
+ visit(messagesPage, navigator.snapshot().entry.id, true);
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+ expect(
+ screen.getByRole("heading", { name: "You’re not in any channels yet" }),
+ ).toBeVisible();
+ // The visit opened on the empty state, so it can no longer be resolved.
+ expect(navigator.snapshot().status).toBe("opened");
+ events.push(
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ );
+ owner.session.channels.refreshList?.();
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ // The same opening, corrected: no way back to the empty state.
+ expect(navigator.snapshot()).toMatchObject({
+ canGoBack: false,
+ entry: { target: { kind: "conversation", channelId: "beta" } },
+ });
+ expect(readView(scope, "selected-channel", undefined)).toBe("beta");
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
it("selects a default channel the relay adds after the page opened", async () => {
const events: RelayEvent[] = [];
const { owner, viewer, authority } = workspace(events);
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index e6882228bb..b20ecc9a30 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -56,6 +56,7 @@ import { lifecycleNoun } from "./ChannelLifecycleDialog";
import type { PageNavigation } from "../../features/navigation/service";
import type {
Navigation,
+ NavigationFocus,
NavigationOpenOptions,
} from "../../features/navigation/controller";
import { targetKey, type OpenTarget } from "../../features/navigation/targets";
@@ -130,6 +131,8 @@ import { channelHasUnread } from "./useSidebarFilters";
import { inFieldOutsideComposer } from "./conversation-rows";
import styles from "./Channels.module.css";
import type { CommunityReader } from "../../features/communities/service";
+import type { CommunityInfo } from "../../features/communities/api";
+import { communityFromScope } from "../../features/relay/gifs";
import { avatarMedia } from "../../shared/avatar-source";
/** The open Solo conversation's Archive, published for the shortcut while its visit is live. */
@@ -152,6 +155,7 @@ export function ChannelsPage({
navigator,
agents,
communities,
+ landing,
archive,
read,
membership,
@@ -163,6 +167,9 @@ export function ChannelsPage({
communities?: CommunityReader | undefined;
/** The viewer's community role, for creating #general in an empty community. */
membership?: CommunityMembership | undefined;
+ /** Reads the relay's welcome-channel support, so a new member's opening can
+ * wait for the channel the relay is adding them to. */
+ landing?: LandingReader | undefined;
relay: RelayData;
navigation?: PageNavigation | undefined;
navigator: Navigation;
@@ -325,6 +332,7 @@ export function ChannelsPage({
agents={agents}
communities={communities}
membership={membership}
+ landing={landing}
/>
)}
@@ -342,6 +350,26 @@ function LiveNewMessage(
/** How long a channel list with nothing to open keeps the opening state. */
const ENROLLMENT_WAIT_MS = 10_000;
+type Landing = Pick;
+export type LandingReader = (
+ community: string,
+ signal: AbortSignal,
+) => Promise;
+
+/** Focus for a default that replaces its visit's opening: focus the reader
+ * has moved out of Messages stays where it is. */
+function correctionFocus(
+ history: HTMLElement | null,
+ focus: NavigationFocus | undefined,
+): NavigationFocus | undefined {
+ const active = document.activeElement;
+ return active &&
+ active !== document.body &&
+ !history?.closest("[data-channel-workspace]")?.contains(active)
+ ? "preserve"
+ : focus;
+}
+
function ChannelWorkspace({
extensions,
chatMenus,
@@ -356,6 +384,7 @@ function ChannelWorkspace({
viewer,
agents,
communities,
+ landing,
archive,
read,
membership,
@@ -366,6 +395,7 @@ function ChannelWorkspace({
membership?: CommunityMembership | undefined;
companion?: ReactNode;
communities?: CommunityReader | undefined;
+ landing?: LandingReader | undefined;
scope: string;
navigation?: PageNavigation | undefined;
navigator: Navigation;
@@ -582,10 +612,59 @@ function ChannelWorkspace({
const welcomeChannel = channels.find(
(channel) => channel.landing && channel.channelType === "stream",
);
+ /** When the list first loaded; the opening waits are measured from it. */
+ const listed = useRef(undefined);
+ // With nothing saved or requested, the relay's NIP-11 document names its
+ // welcome channel before the list can: a new member's list may not have it yet.
+ const community = communityFromScope(scope);
+ const needsLanding =
+ !!landing &&
+ !!community &&
+ !cached &&
+ !emptyDestination &&
+ !requestedChannel &&
+ !kept &&
+ (!selected || list.status === "ready");
+ const [landingRead, setLandingRead] = useState();
+ useEffect(() => {
+ if (!needsLanding || landingRead || !landing || !community) return;
+ const reading = new AbortController();
+ landing(community, reading.signal).then(
+ (info) => !reading.signal.aborted && setLandingRead(info),
+ () => !reading.signal.aborted && setLandingRead("failed"),
+ );
+ return () => reading.abort();
+ }, [needsLanding, landingRead, landing, community]);
+ const landingInfo = landingRead === "failed" ? undefined : landingRead;
+ const awaited = landingInfo?.landingChannel;
+ // While that read is pending, or names a channel not listed yet, no other
+ // channel opens in its place, until ENROLLMENT_WAIT_MS after the list loaded.
+ const [heldOut, setHeldOut] = useState(false);
+ const holdFallback =
+ needsLanding &&
+ !heldOut &&
+ !welcomeChannel &&
+ list.status !== "error" &&
+ (!landingRead ||
+ (!!awaited && !list.channels.some((channel) => channel.id === awaited)));
+ useEffect(() => {
+ if (!holdFallback || list.status !== "ready") return;
+ const timer = setTimeout(
+ () => setHeldOut(true),
+ Math.max(
+ 0,
+ (listed.current ?? Date.now()) + ENROLLMENT_WAIT_MS - Date.now(),
+ ),
+ );
+ return () => clearTimeout(timer);
+ }, [holdFallback, list.status]);
// A channel whose metadata is still pending can be the welcome channel, so
// the starter and first-channel fallbacks wait for it rather than settle first.
const fallback =
- kept || welcomeChannel || (metadataPending && list.status !== "error")
+ kept ||
+ welcomeChannel ||
+ holdFallback ||
+ (metadataPending && list.status !== "error")
? undefined
: (channels.find(
(channel) => channel.autoJoin && channel.channelType !== "solo",
@@ -619,30 +698,34 @@ function ChannelWorkspace({
// A list with nothing to open can be a new member's, read before the relay
// has added them to the welcome channel. Any such empty default keeps
// opening for a bounded time, once per workspace, before offering recovery;
- // Try again refreshes the list and waits once more.
+ // a relay without welcome channels adds no one, so its first list does not
+ // wait. Try again refreshes the list and waits once more.
const [listAttempt, setListAttempt] = useState(0);
const [waitedOut, setWaitedOut] = useState(-1);
- const noChannels = emptyList && waitedOut === listAttempt;
+ const unenrolled =
+ waitedOut === listAttempt ||
+ (listAttempt === 0 && landingInfo?.landingChannels === false);
+ const noChannels = emptyList && unenrolled;
useEffect(() => {
- if (!emptyList || waitedOut === listAttempt) return;
+ if (!emptyList || unenrolled) return;
const timer = setTimeout(
() => setWaitedOut(listAttempt),
ENROLLMENT_WAIT_MS,
);
return () => clearTimeout(timer);
- }, [emptyList, listAttempt, waitedOut]);
+ }, [emptyList, listAttempt, unenrolled]);
const opening =
defaulting &&
(list.status === "idle" ||
list.status === "loading" ||
metadataPending ||
+ holdFallback ||
(emptyList && !noChannels));
// The relay's membership hints can confirm a new member's starter channel
// before their welcome channel, so a fallback chosen with no welcome channel
// listed is provisional. Until the reader clicks or types anywhere or moves
// to another visit, a welcome channel arriving within ENROLLMENT_WAIT_MS of
// the list first loading replaces it.
- const listed = useRef(undefined);
const provisional = useRef<{
channelId: string;
entryId: string | undefined;
@@ -710,16 +793,7 @@ function ChannelWorkspace({
: shown?.id !== choice.entryId)
)
return;
- // Focus the reader has moved out of Messages stays where it is.
- const active = document.activeElement;
- const focus =
- active &&
- active !== document.body &&
- !historyContainer.current
- ?.closest("[data-channel-workspace]")
- ?.contains(active)
- ? "preserve"
- : visitFocus;
+ const focus = correctionFocus(historyContainer.current, visitFocus);
// The same opening, corrected: no return path to the fallback.
navigate(welcomeId, { replace: true, ...(focus && { focus }) });
}, [welcomeId, currentId, visitFocus, navigate, navigator, settleDefault]);
@@ -741,6 +815,8 @@ function ChannelWorkspace({
}
}, [joiningChannel, current]);
const CurrentChannelIcon = channelIcon(current);
+ /** The opened visit a late default last replaced, which it does once. */
+ const corrected = useRef(undefined);
useEffect(() => {
if (navigation?.signal.aborted) return;
if (composingMessage || draft) {
@@ -769,7 +845,7 @@ function ChannelWorkspace({
navigation?.complete({ status: "opened" });
if (!requestedChannel && current && navigation && viewer) {
// Resolve the saved default within this attempt, keeping its caller and deadline.
- navigation.resolve({
+ const resolved = navigation.resolve({
version: 1,
kind: "conversation",
channelId: current.id,
@@ -778,6 +854,25 @@ function ChannelWorkspace({
communityOrigin: scope.slice(0, -(viewer.length + 1)),
},
});
+ // The visit opened with nothing to show, as the opening waits do. The
+ // welcome channel arriving since, or the fallback once a held opening
+ // gave up on it, replaces that visit so its route and selection follow.
+ // Only while the visit is shown, never Settings or a visit opened since.
+ const shown = navigator.snapshot();
+ if (
+ !resolved &&
+ (current.id === welcomeId || heldOut) &&
+ shown.status === "opened" &&
+ shown.entry.id === navigation.entryId &&
+ corrected.current !== navigation.entryId
+ ) {
+ corrected.current = navigation.entryId;
+ const focus = correctionFocus(
+ historyContainer.current,
+ navigation.focus,
+ );
+ navigate(current.id, { replace: true, ...(focus && { focus }) });
+ }
}
}, [
cached,
@@ -790,8 +885,12 @@ function ChannelWorkspace({
current,
list,
navigation,
+ navigator,
+ navigate,
viewer,
scope,
+ welcomeId,
+ heldOut,
]);
const requestedMessage =
navigation?.target.kind === "conversation"
diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx
index 423c2796b3..09073c4e06 100644
--- a/src/bundled/channels/index.tsx
+++ b/src/bundled/channels/index.tsx
@@ -9,12 +9,23 @@ import { registerCanvas } from "./canvas-panel";
import { ChannelTemplateSettings } from "./ChannelTemplateSettings";
import { ChatCircleIcon, CopyIcon } from "../../shared/design-system/icons";
import { isApplePlatform } from "../../features/shortcuts/format";
-import type { ArchiveShortcut, ReadShortcut } from "./ChannelsPage";
+import type {
+ ArchiveShortcut,
+ LandingReader,
+ ReadShortcut,
+} from "./ChannelsPage";
+import {
+ communityRequest,
+ type CommunityInfo,
+} from "../../features/communities/api";
import {
conversationRows,
inTerminal,
stepConversation,
} from "./conversation-rows";
+/** The relay's welcome-channel support and designation, read live from NIP-11. */
+const readLanding: LandingReader = (community, signal) =>
+ communityRequest(community, "info", undefined, signal);
export const inject = [
"pages",
"relay",
@@ -148,6 +159,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
agents={agents}
communities={ctx.communityReader}
membership={membership}
+ landing={readLanding}
/>
),
});
From 1ce215301036556d966130ba60c526306d15173f Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 21:35:02 +1100
Subject: [PATCH 15/28] fix(communities): settle review findings on the welcome
form and opening hold
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Welcome form: a Check again whose NIP-11 read finds the relay no longer
supports welcome channels ends the welcome draft and its unconfirmed
notice instead of leaving a loading Check again and a disabled Save.
Starter edits announce the pending change through a polite live region,
Cancel announces the discard, and focus on a Save or Cancel that leaves
with the action row rests on the welcome channel field (or the add field
without a welcome form); a refused addition hands focus to the add field
beside its reason.
Messages: the NIP-11 hold for an unlisted welcome channel applies only
while the list still looks like a new member's (starter channels and DMs
only), so established members are not held for 10 s on a new device. A
saved selection the list has, metadata pending or not, skips the read.
Validators: the native host mirrors the relay's blank and direction
rules exactly (White_Space or the INVISIBLE set is blank; BIDI refused
anywhere), and both it and landing.ts add U+1BCA0–U+1BCA3 and
U+E0100–U+E01EF to the INVISIBLE set.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 9 +-
docs/communities.md | 7 +-
src-tauri/src/relay/channel_writes.rs | 111 +++++++++++++++++-
.../channels/ChannelsPage.tabs.test.tsx | 69 ++++++++++-
src/bundled/channels/ChannelsPage.tsx | 18 ++-
.../moderation/WelcomeSettings.test.tsx | 54 +++++++++
src/bundled/moderation/WelcomeSettings.tsx | 55 ++++++++-
src/features/relay/landing.test.ts | 1 +
src/features/relay/landing.ts | 5 +-
9 files changed, 309 insertions(+), 20 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index 2db038beba..d11bcc5147 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1311,8 +1311,13 @@ first-channel fallbacks wait. They also wait for the relay's NIP-11 document,
read when Messages has nothing saved or requested to open, and while the
welcome channel it names is not listed yet: no DM or other channel opens in its
place until it arrives or ten seconds pass from the channel list first loading.
-The channel opened when that wait ends is saved as the selection, so the next
-opening does not wait again. The relay's membership hints can still add a new
+That wait applies only while the list still looks like a new member's, with
+nothing but the relay's starter channels and DMs in it; once a channel the
+person joined themselves is listed, the relay is not adding them to the welcome
+channel, so the fallback opens at once, as for a member from before it was
+designated or one who left it. The channel opened when the wait ends is saved
+as the selection, so the next opening does not wait again. A saved selection
+the list has, even with its metadata still arriving, needs no NIP-11 read. The relay's membership hints can still add a new
member to their starter channel before their welcome channel, so a fallback
opened while no welcome channel is listed is provisional: a welcome channel that
arrives within ten seconds of the channel list first loading replaces it, unless
diff --git a/docs/communities.md b/docs/communities.md
index 9f8c8254bc..2b85a60e97 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -216,7 +216,12 @@ in turn, and confirms each step as described in
[channel lifecycle](channels.md#channel-lifecycle); one "Welcome saved" notice
follows a complete save. A relay refusal of the welcome is shown with its reason
and the draft is kept. A step the relay has not shown yet keeps the draft with
-Save disabled until **Check again** reads the relay's state for that step. Archive,
+Save disabled until **Check again** reads the relay's state for that step. If
+that read finds the relay no longer supports welcome channels, the welcome draft
+and its notice end with it, and the starter list stays. **Add a channel**,
+**Remove** and **Cancel** announce the pending or discarded change for screen
+readers, and focus on a Save or Cancel that leaves rests on the welcome channel
+field, or on the search where there is no welcome form. Archive,
Delete, Make private and expiry are not disabled for the welcome channel yet; the
relay refuses them, and that refusal is shown like any other lifecycle error.
diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs
index 99d7995b31..f24893c4a8 100644
--- a/src-tauri/src/relay/channel_writes.rs
+++ b/src-tauri/src/relay/channel_writes.rs
@@ -83,23 +83,65 @@ fn details_labels(labels: &[Vec]) -> bool {
}
}
}
+/// The relay's invisible characters: soft hyphen, fillers, joiners and
+/// direction marks, variation selectors, Braille blank, BOM, shorthand and
+/// musical format controls, tags and the like. `landing.ts` lists the same.
+fn invisible(c: char) -> bool {
+ matches!(
+ c,
+ '\u{AD}'
+ | '\u{34F}'
+ | '\u{61C}'
+ | '\u{115F}'
+ | '\u{1160}'
+ | '\u{17B4}'
+ | '\u{17B5}'
+ | '\u{180B}'..='\u{180F}'
+ | '\u{200B}'..='\u{200F}'
+ | '\u{202A}'..='\u{202E}'
+ | '\u{2060}'..='\u{206F}'
+ | '\u{2800}'
+ | '\u{3164}'
+ | '\u{FE00}'..='\u{FE0F}'
+ | '\u{FEFF}'
+ | '\u{FFA0}'
+ | '\u{FFF9}'..='\u{FFFB}'
+ | '\u{1BCA0}'..='\u{1BCA3}'
+ | '\u{1D173}'..='\u{1D17A}'
+ | '\u{E0000}'..='\u{E007F}'
+ | '\u{E0100}'..='\u{E01EF}'
+ )
+}
+/// The relay's blank rule: every scalar value is Unicode White_Space or invisible.
+fn blank(text: &str) -> bool {
+ text.chars().all(|c| c.is_whitespace() || invisible(c))
+}
+/// Bidirectional marks, embeddings, overrides and isolates, refused anywhere.
+fn bidi(c: char) -> bool {
+ matches!(
+ c,
+ '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
+ )
+}
/// The welcome message is `""` (restore the default) or 1–4000 scalar values
-/// of Markdown source whose only control characters are `\n` and `\t`,
-/// matching the relay's rules.
+/// of Markdown source that is not blank, has no direction characters, and
+/// whose only control characters are `\n` and `\t`, matching the relay's rules.
fn welcome(text: &str) -> bool {
text.is_empty()
- || (!text.trim().is_empty()
+ || (!blank(text)
+ && !text.chars().any(bidi)
&& text.chars().count() <= 4000
&& text
.chars()
.all(|c| matches!(c, '\n' | '\t') || !c.is_control()))
}
/// The welcome title is `""` (restore the default) or 1–120 scalar values on
-/// one line: no control characters and no Unicode line or paragraph
-/// separator, matching the relay's rules.
+/// one line: not blank, no direction characters, no control characters and no
+/// Unicode line or paragraph separator, matching the relay's rules.
fn welcome_title(text: &str) -> bool {
text.is_empty()
- || (!text.trim().is_empty()
+ || (!blank(text)
+ && !text.chars().any(bidi)
&& text.chars().count() <= 120
&& !text
.chars()
@@ -626,6 +668,63 @@ mod boundary_tests {
content["content"] = serde_json::json!("x");
assert!(validate("channel-lifecycle", &content, None).is_err());
}
+
+ /// The same cases as `landing.test.ts`: text of only whitespace and
+ /// invisible characters is blank, and direction characters are refused
+ /// anywhere, for the message and the title alike.
+ #[test]
+ fn landing_command_mirrors_the_relay_blank_and_direction_rules() {
+ let channel = uuid::Uuid::nil().to_string();
+ let event = serde_json::json!({
+ "kind": 9002,
+ "created_at": 1700000010,
+ "content": "",
+ "tags": [
+ ["h", channel],
+ ["landing", "true"],
+ ["welcome", ""],
+ ["welcome_title", ""]
+ ]
+ });
+ let accepted = |index: usize, text: &str| {
+ let mut command = event.clone();
+ command["tags"][index][1] = serde_json::json!(text);
+ validate("channel-lifecycle", &command, None).is_ok()
+ };
+ for text in [
+ "\u{200B}",
+ "\u{2800}",
+ " \u{200B} ",
+ "\u{FEFF}",
+ "\u{AD}\u{34F}\u{115F}\u{1160}\u{3164}\u{FFA0}",
+ "\u{180B}\u{2060}\u{206F}\u{FE00}\u{FE0F}\u{FFF9}",
+ "\u{1D173}\u{E0000}\u{E007F}",
+ "\u{1BCA0}\u{1BCA3}\u{E0100}\u{E01EF}",
+ "\u{200E}\u{200F}",
+ "\u{2003}\u{3000}",
+ " \n ",
+ ] {
+ assert!(!accepted(2, text), "blank message {text:?}");
+ assert!(!accepted(3, text), "blank title {text:?}");
+ }
+ // Invisible characters beside visible text are kept.
+ assert!(accepted(2, "Hello\u{200B}world"));
+ assert!(accepted(3, "Hello\u{200B}world"));
+ for mark in [
+ "\u{61C}", "\u{200E}", "\u{200F}", "\u{202A}", "\u{202E}", "\u{2066}", "\u{2069}",
+ ] {
+ assert!(
+ !accepted(2, &format!("{mark}abc")),
+ "direction in message {mark:?}"
+ );
+ assert!(
+ !accepted(3, &format!("a{mark}bc")),
+ "direction in title {mark:?}"
+ );
+ }
+ let overlong = format!("\u{202E}{}", "a".repeat(4001));
+ assert!(!accepted(2, &overlong));
+ }
#[test]
fn lifecycle_accepts_only_exact_archive_and_auto_join_values() {
for flag in ["archived", "auto_join"] {
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index d2c585b800..28e8ccab3e 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -682,15 +682,21 @@ it("shows an empty list at once on a relay without welcome channels", async () =
expect(landing).toHaveBeenCalledWith("https://relay.test", expect.anything());
});
-/** A new member listed only in a DM so far, on a relay whose NIP-11 names
- * `beta` as its welcome channel. */
-async function awaitingWelcome(hosted = false) {
+/** A member listed in a DM, and with `joined` in a stream channel they joined
+ * themselves, on a relay whose NIP-11 names `beta` as its welcome channel. */
+async function awaitingWelcome({ hosted = false, joined = false } = {}) {
const viewer = keypair(),
authority = keypair(),
other = keypair();
const events: RelayEvent[] = [
roster(authority, "pat", [viewer.pubkey, other.pubkey]),
metadata(authority, "pat", "Pat", undefined, [["t", "dm"]]),
+ ...(joined
+ ? [
+ roster(authority, "alpha", [viewer.pubkey, other.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]),
+ ]
+ : []),
];
const landing = vi.fn(async () => ({
landingChannels: true,
@@ -729,10 +735,65 @@ it("waits for the welcome channel the relay names rather than open a DM, then op
expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
});
+it("opens a channel the person joined themselves at once, though the relay names an unlisted welcome channel", async () => {
+ // Only a new member's list, of starter channels and DMs, is still being
+ // filled by the relay; an established member's is not held.
+ await awaitingWelcome({ joined: true });
+ expect(
+ await screen.findByRole("textbox", { name: "Message #Alpha" }),
+ ).toBeVisible();
+ expect(screen.queryByText("Opening your channels…")).toBeNull();
+});
+
+it("skips the NIP-11 read for a saved selection whose metadata is still arriving", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ let release = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const landing = vi.fn(async () => ({
+ landingChannels: true,
+ landingChannel: "beta",
+ }));
+ const scope = `https://relay.test:${viewer.pubkey}`;
+ writeView(scope, "selected-channel", "alpha");
+ const { owner } = workspace(
+ [
+ roster(authority, "alpha", [viewer.pubkey]),
+ metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]),
+ ],
+ viewer,
+ authority,
+ (filters) =>
+ filters.some(
+ (filter) =>
+ filter.kinds?.includes(39000) && filter["#d"]?.includes("alpha"),
+ )
+ ? held
+ : undefined,
+ landing,
+ );
+ try {
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ expect(
+ owner.session.channels.list().channels.find(({ id }) => id === "alpha")
+ ?.metadataPending,
+ ).toBe(true);
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ } finally {
+ release();
+ }
+ await screen.findByRole("textbox", { name: "Message #Alpha" });
+ expect(landing).not.toHaveBeenCalled();
+});
+
it("opens and keeps the DM once the welcome channel the relay names stays unlisted", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
- const { scope, navigator } = await awaitingWelcome(true);
+ const { scope, navigator } = await awaitingWelcome({ hosted: true });
// The visit opened on the wait; the DM then replaces it.
expect(navigator.snapshot().status).toBe("opened");
await act(() => vi.advanceTimersByTimeAsync(9_000));
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index b20ecc9a30..f49d2feb7e 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -615,7 +615,8 @@ function ChannelWorkspace({
/** When the list first loaded; the opening waits are measured from it. */
const listed = useRef(undefined);
// With nothing saved or requested, the relay's NIP-11 document names its
- // welcome channel before the list can: a new member's list may not have it yet.
+ // welcome channel before the list can: a new member's list may not have it
+ // yet. A saved selection the list has, metadata pending or not, needs no read.
const community = communityFromScope(scope);
const needsLanding =
!!landing &&
@@ -624,7 +625,9 @@ function ChannelWorkspace({
!emptyDestination &&
!requestedChannel &&
!kept &&
- (!selected || list.status === "ready");
+ (!selected ||
+ (list.status === "ready" &&
+ !list.channels.some((channel) => channel.id === selected)));
const [landingRead, setLandingRead] = useState();
useEffect(() => {
if (!needsLanding || landingRead || !landing || !community) return;
@@ -639,11 +642,22 @@ function ChannelWorkspace({
const awaited = landingInfo?.landingChannel;
// While that read is pending, or names a channel not listed yet, no other
// channel opens in its place, until ENROLLMENT_WAIT_MS after the list loaded.
+ // Only while the list still looks like a new member's: nothing but the
+ // relay's starter channels and DMs. A channel the person joined themselves
+ // means the relay is not adding them to the welcome channel now, as for a
+ // member from before it was designated, or one who left it.
const [heldOut, setHeldOut] = useState(false);
+ const newcomer = channels.every(
+ (channel) =>
+ channel.autoJoin ||
+ channel.channelType === "dm" ||
+ channel.channelType === "solo",
+ );
const holdFallback =
needsLanding &&
!heldOut &&
!welcomeChannel &&
+ newcomer &&
list.status !== "error" &&
(!landingRead ||
(!!awaited && !list.channels.some((channel) => channel.id === awaited)));
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 3af04816fd..a1d1df91d9 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -821,6 +821,56 @@ it("keeps the draft in view during the check and reopens Save when the change is
expect(screen.queryByText("Welcome saved")).toBeNull();
});
+it("ends an unconfirmed welcome save, with its draft, when the relay stops supporting welcome channels", async () => {
+ const fixture = setup();
+ const { user } = fixture;
+ await unconfirmedSave(fixture);
+ vi.mocked(communityRequest).mockResolvedValueOnce({
+ policy: null,
+ landingChannels: false,
+ });
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ expect(
+ await screen.findByText(
+ "This community’s relay doesn’t support a welcome channel.",
+ ),
+ ).toBeVisible();
+ // Nothing of the welcome can be saved here now: no notice, check or draft.
+ expect(screen.queryByText(/Not confirmed yet/)).toBeNull();
+ expect(screen.queryByRole("button", { name: "Check again" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
+ // The starter list stays usable, and takes the focus the check had.
+ expect(remove("Town square")).toBeEnabled();
+ expect(addField()).toHaveFocus();
+});
+
+it("announces starter edits, and rests focus in the pane when Cancel or Save takes the action row away", async () => {
+ const { user } = setup();
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ await user.click(remove("Town square"));
+ expect(
+ screen.getByText("#Town square will be removed when you save"),
+ ).toHaveAttribute("role", "status");
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
+ expect(
+ screen.getByText("#Random will be added when you save"),
+ ).toHaveAttribute("role", "status");
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.getByText("Starter changes discarded")).toHaveAttribute(
+ "role",
+ "status",
+ );
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(channel()).toHaveFocus();
+ await user.click(remove("Town square"));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ expect(channel()).toHaveFocus();
+});
+
it("shows Save only for a change, and Cancel returns to the published state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
@@ -1151,6 +1201,8 @@ it("explains a refused removal beside its row and a refused addition on the sear
"#Random: Only community owners and admins can change this.",
);
expect(removable("Random")).toBeNull();
+ // The reason is on the search, and so is the focus Save had.
+ expect(addField()).toHaveFocus();
// Adding it again clears the reason.
await user.type(addField(), "ran");
await user.click(await screen.findByRole("option", { name: "Random" }));
@@ -1307,4 +1359,6 @@ it("still lists and saves starter channels when the relay has no welcome channel
expect.any(Function),
);
expect(removable("Town square")).toBeNull();
+ // With no welcome form, focus from the departed Save rests on the search.
+ expect(addField()).toHaveFocus();
});
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 919be54b65..808167a1fc 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -3,6 +3,7 @@ import {
type RefObject,
useEffect,
useId,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -305,14 +306,18 @@ function WelcomeEditor({
const [unconfirmed, setUnconfirmed] = useState<"welcome" | StarterStep>();
const [checking, setChecking] = useState(false);
const [saved, setSaved] = useState(false);
+ // Starter edits change the list without moving focus; this tells of them.
+ const [announcement, setAnnouncement] = useState("");
// The check compares the relay's answer with the draft as it stands then.
const draftRef = useRef(draft);
draftRef.current = draft;
// The starter steps Save would run, for a check deciding whether all landed.
const pendingSteps = useRef([]);
const checkButton = useRef(null);
+ const channelInput = useRef(null);
const messageInput = useRef(null);
const addInput = useRef(null);
+ const actionsRow = useRef(null);
const mounted = useRef(true);
const saveController = useRef(undefined);
useEffect(() => {
@@ -341,6 +346,14 @@ function WelcomeEditor({
reading.signal.throwIfAborted();
if (!info.landingChannels) {
setLoad({ status: "unsupported" });
+ // The welcome can no longer be saved here, so its draft and an
+ // unconfirmed save of it end with this read.
+ setDraft(undefined);
+ setUnconfirmed((current) =>
+ current === "welcome" ? undefined : current,
+ );
+ setChecking(false);
+ if (check) stayInForm(checkButton.current, addInput.current);
return;
}
const id = info.landingChannel;
@@ -462,9 +475,17 @@ function WelcomeEditor({
);
setStarterErrors((current) => without(current, channel.id));
if (enabled || addError?.id === channel.id) setAddError(undefined);
+ setAnnouncement(
+ `#${channel.name} will be ${enabled ? "added" : "removed"} when you save`,
+ );
setSaved(false);
setError("");
};
+ // Cancel and Save take the action row away; focus on it rests on the
+ // welcome channel field, or the add field where there is no welcome form.
+ const restFocus = () =>
+ (channelInput.current ?? addInput.current)?.focus({ preventScroll: true });
+ const actionsShown = useRef(false);
const isActive = () => mounted.current && active();
// The welcome first, then each starter flag in turn. A confirmed step is
// published; a refused one returns to its published state, where its reason
@@ -531,8 +552,12 @@ function WelcomeEditor({
else {
const { id, enabled } = step;
setStarterDraft((current) => without(current, id));
- if (enabled) setAddError({ id, message: saveError(reason) });
- else setStarterErrors(new Map([[id, saveError(reason)]]));
+ if (enabled) {
+ setAddError({ id, message: saveError(reason) });
+ // The reason shows on the add field; focus from Save follows it.
+ if (actionsRow.current?.contains(document.activeElement))
+ addInput.current?.focus({ preventScroll: true });
+ } else setStarterErrors(new Map([[id, saveError(reason)]]));
}
} finally {
if (mounted.current) setSaving(false);
@@ -592,7 +617,10 @@ function WelcomeEditor({
/>
);
const actions = (draft !== undefined || steps.length > 0) && (
-
+
Cancel
@@ -652,6 +681,18 @@ function WelcomeEditor({
{actions}
);
+ // A Save or Cancel that was focused leaves with the row; an element that
+ // stays takes the focus, which would otherwise fall to the document.
+ useLayoutEffect(() => {
+ const shown = !!actions;
+ if (
+ actionsShown.current &&
+ !shown &&
+ (!document.activeElement || document.activeElement === document.body)
+ )
+ restFocus();
+ actionsShown.current = shown;
+ });
const toast = saved && (
setSaved(false)}
/>
);
+ const status = (
+
+ {announcement}
+
+ );
return (
{starters}
+ {status}
{footer}
{toast}
@@ -721,6 +768,7 @@ function WelcomeEditor({
autoHighlight
>
{starters}
+ {status}
{footer}
{toast}
diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts
index f0b0211047..738e02dd04 100644
--- a/src/features/relay/landing.test.ts
+++ b/src/features/relay/landing.test.ts
@@ -130,6 +130,7 @@ describe("welcome blank and direction rules", () => {
"\u00AD\u034F\u115F\u1160\u3164\uFFA0",
"\u180B\u2060\u206F\uFE00\uFE0F\uFFF9",
"\u{1D173}\u{E0000}\u{E007F}",
+ "\u{1BCA0}\u{1BCA3}\u{E0100}\u{E01EF}",
]) {
expect(welcomeTextProblem(text)).toBe("empty");
expect(welcomeTitleProblem(text)).toBe("empty");
diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts
index d6a85b264a..1999702bf7 100644
--- a/src/features/relay/landing.ts
+++ b/src/features/relay/landing.ts
@@ -44,10 +44,11 @@ export function normalizeWelcome(text: string) {
// The relay's blank rule: every code point is Unicode White_Space or one of
// these invisible ones (soft hyphen, fillers, joiners and direction marks,
-// variation selectors, Braille blank, BOM, tags and the like).
+// variation selectors, Braille blank, BOM, shorthand and musical format
+// controls, tags and the like). The native host's validator lists the same.
const blank = (text: string) =>
// biome-ignore lint/suspicious/noMisleadingCharacterClass: each listed code point is matched on its own, joiners and selectors included.
- /^[\p{White_Space}\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2800\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF9-\uFFFB\u{1D173}-\u{1D17A}\u{E0000}-\u{E007F}]*$/u.test(
+ /^[\p{White_Space}\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2800\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF9-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]*$/u.test(
text,
);
// Bidirectional marks, embeddings, overrides and isolates, refused anywhere.
From 60d398583e159fb4a0dfb2b7e3f88b6706ea6b4e Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 22:15:06 +1100
Subject: [PATCH 16/28] fix(communities): announce starter undos, keep the
starter list across a support re-read
A starter edit that returns a channel to its published flag now announces
the undo ("won't be added" / "stays a starter channel") rather than the
reverse change; an identical announcement is cleared first so it is heard
again, and Save withdraws it.
The NIP-11 skip for a saved selection now applies only while that row's
metadata is pending: a listed visible selection is already kept, and an
archived one no longer opens, so a newcomer's list with an archived
selection reads NIP-11 and holds as before.
The welcome form and the starter list share one container, so the list,
its live region, typed search and focus survive the relay's support
changing on a re-read instead of remounting.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 3 +-
docs/communities.md | 4 +-
.../channels/ChannelsPage.tabs.test.tsx | 36 ++++++++++++
src/bundled/channels/ChannelsPage.tsx | 7 ++-
.../moderation/WelcomeSettings.test.tsx | 42 ++++++++++++++
src/bundled/moderation/WelcomeSettings.tsx | 56 +++++++++++--------
6 files changed, 119 insertions(+), 29 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index d11bcc5147..fe737d54f8 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1317,7 +1317,8 @@ person joined themselves is listed, the relay is not adding them to the welcome
channel, so the fallback opens at once, as for a member from before it was
designated or one who left it. The channel opened when the wait ends is saved
as the selection, so the next opening does not wait again. A saved selection
-the list has, even with its metadata still arriving, needs no NIP-11 read. The relay's membership hints can still add a new
+whose metadata is still arriving needs no NIP-11 read; an archived one no
+longer opens, so it does not count. The relay's membership hints can still add a new
member to their starter channel before their welcome channel, so a fallback
opened while no welcome channel is listed is provisional: a welcome channel that
arrives within ten seconds of the channel list first loading replaces it, unless
diff --git a/docs/communities.md b/docs/communities.md
index 2b85a60e97..ace447f5d2 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -219,8 +219,8 @@ and the draft is kept. A step the relay has not shown yet keeps the draft with
Save disabled until **Check again** reads the relay's state for that step. If
that read finds the relay no longer supports welcome channels, the welcome draft
and its notice end with it, and the starter list stays. **Add a channel**,
-**Remove** and **Cancel** announce the pending or discarded change for screen
-readers, and focus on a Save or Cancel that leaves rests on the welcome channel
+**Remove** and **Cancel** announce the pending, undone or discarded change for
+screen readers, and focus on a Save or Cancel that leaves rests on the welcome channel
field, or on the search where there is no welcome form. Archive,
Delete, Make private and expiry are not disabled for the welcome channel yet; the
relay refuses them, and that refusal is shown like any other lifecycle error.
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 28e8ccab3e..082cf3a48b 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -790,6 +790,42 @@ it("skips the NIP-11 read for a saved selection whose metadata is still arriving
expect(landing).not.toHaveBeenCalled();
});
+it("reads NIP-11 and holds for a newcomer's list whose saved selection is archived", async () => {
+ const viewer = keypair(),
+ authority = keypair(),
+ other = keypair();
+ const landing = vi.fn(async () => ({
+ landingChannels: true,
+ landingChannel: "beta",
+ }));
+ const scope = `https://relay.test:${viewer.pubkey}`;
+ // An archived selection is listed, but not shown or read; the rest of the
+ // list is a DM, as a new member's can be.
+ writeView(scope, "selected-channel", "old");
+ const { owner } = workspace(
+ [
+ roster(authority, "old", [viewer.pubkey]),
+ metadata(authority, "old", "Old", undefined, [
+ ["t", "stream"],
+ ["archived", "true"],
+ ]),
+ roster(authority, "pat", [viewer.pubkey, other.pubkey]),
+ metadata(authority, "pat", "Pat", undefined, [["t", "dm"]]),
+ ],
+ viewer,
+ authority,
+ undefined,
+ landing,
+ );
+ await vi.waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
+ await vi.waitFor(() => expect(landing).toHaveBeenCalledOnce());
+ await act(async () => {});
+ expect(screen.getByText("Opening your channels…")).toBeVisible();
+ expect(screen.queryByRole("textbox")).toBeNull();
+});
+
it("opens and keeps the DM once the welcome channel the relay names stays unlisted", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index f49d2feb7e..30c0368b61 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -616,7 +616,8 @@ function ChannelWorkspace({
const listed = useRef(undefined);
// With nothing saved or requested, the relay's NIP-11 document names its
// welcome channel before the list can: a new member's list may not have it
- // yet. A saved selection the list has, metadata pending or not, needs no read.
+ // yet. A saved selection whose metadata is still arriving needs no read; one
+ // the list shows is `kept`, and an archived one is no longer being read.
const community = communityFromScope(scope);
const needsLanding =
!!landing &&
@@ -627,7 +628,9 @@ function ChannelWorkspace({
!kept &&
(!selected ||
(list.status === "ready" &&
- !list.channels.some((channel) => channel.id === selected)));
+ !list.channels.some(
+ (channel) => channel.id === selected && channel.metadataPending,
+ )));
const [landingRead, setLandingRead] = useState();
useEffect(() => {
if (!needsLanding || landingRead || !landing || !community) return;
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index a1d1df91d9..79424651c4 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -857,6 +857,21 @@ it("announces starter edits, and rests focus in the pane when Cancel or Save tak
expect(
screen.getByText("#Random will be added when you save"),
).toHaveAttribute("role", "status");
+ // Back to the published flags: each is an undo, not the reverse change.
+ await user.click(remove("Random"));
+ expect(screen.getByText("#Random won’t be added")).toHaveAttribute(
+ "role",
+ "status",
+ );
+ await user.type(addField(), "town");
+ await user.click(await screen.findByRole("option", { name: "Town square" }));
+ expect(
+ screen.getByText("#Town square stays a starter channel"),
+ ).toHaveAttribute("role", "status");
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ await user.click(remove("Town square"));
+ await user.type(addField(), "ran");
+ await user.click(await screen.findByRole("option", { name: "Random" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByText("Starter changes discarded")).toHaveAttribute(
"role",
@@ -867,10 +882,37 @@ it("announces starter edits, and rests focus in the pane when Cancel or Save tak
await user.click(remove("Town square"));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
+ // Save speaks for itself; the edit's announcement is withdrawn.
+ expect(screen.queryByText(/when you save/)).toBeNull();
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
expect(channel()).toHaveFocus();
});
+it("keeps the starter list, its typed search and focus when a re-read finds the relay no longer supports welcome channels", async () => {
+ const fixture = setup();
+ const { user } = fixture;
+ await waitFor(() => expect(channel()).toHaveValue("Lobby"));
+ await user.click(remove("Town square"));
+ await unconfirmedSave(fixture);
+ await user.type(addField(), "ran");
+ vi.mocked(communityRequest).mockResolvedValueOnce({
+ policy: null,
+ landingChannels: false,
+ });
+ await user.click(screen.getByRole("button", { name: "Check again" }));
+ expect(
+ await screen.findByText(
+ "This community’s relay doesn’t support a welcome channel.",
+ ),
+ ).toBeVisible();
+ // The same list, not a new one: the search and the pending removal stay.
+ expect(addField()).toHaveValue("ran");
+ expect(addField()).toHaveFocus();
+ expect(removable("Town square")).toBeNull();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
+});
+
it("shows Save only for a change, and Cancel returns to the published state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 808167a1fc..2b62d93535 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -39,6 +39,7 @@ import {
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import { matchName } from "../../features/search/match";
+import { flushSync } from "react-dom";
import { Button } from "../../shared/design-system/ui/Button";
import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
import { Combobox } from "../../shared/design-system/ui/Combobox";
@@ -308,6 +309,11 @@ function WelcomeEditor({
const [saved, setSaved] = useState(false);
// Starter edits change the list without moving focus; this tells of them.
const [announcement, setAnnouncement] = useState("");
+ // Cleared first, so the same words said twice are heard twice.
+ const announce = (text: string) => {
+ flushSync(() => setAnnouncement(""));
+ setAnnouncement(text);
+ };
// The check compares the relay's answer with the draft as it stands then.
const draftRef = useRef(draft);
draftRef.current = draft;
@@ -468,15 +474,21 @@ function WelcomeEditor({
setError("");
};
const editStarter = (channel: Option, enabled: boolean) => {
+ // Back to the published flag: the drafted change is undone, not reversed.
+ const undo = enabled === channel.autoJoin;
setStarterDraft((current) =>
- enabled === channel.autoJoin
+ undo
? without(current, channel.id)
: new Map(current).set(channel.id, enabled),
);
setStarterErrors((current) => without(current, channel.id));
if (enabled || addError?.id === channel.id) setAddError(undefined);
- setAnnouncement(
- `#${channel.name} will be ${enabled ? "added" : "removed"} when you save`,
+ announce(
+ undo
+ ? enabled
+ ? `#${channel.name} stays a starter channel`
+ : `#${channel.name} won’t be added`
+ : `#${channel.name} will be ${enabled ? "added" : "removed"} when you save`,
);
setSaved(false);
setError("");
@@ -509,6 +521,7 @@ function WelcomeEditor({
setError("");
setStarterErrors(new Map());
setAddError(undefined);
+ setAnnouncement("");
void (async () => {
let step: StarterStep | undefined;
try {
@@ -630,7 +643,7 @@ function WelcomeEditor({
setError("");
setStarterErrors(new Map());
setAddError(undefined);
- if (steps.length) setAnnouncement("Starter changes discarded");
+ if (steps.length) announce("Starter changes discarded");
}}
>
Cancel
@@ -727,20 +740,15 @@ function WelcomeEditor({
) : undefined
}
>
- {!supported ? (
-
- {starters}
- {status}
- {footer}
- {toast}
-
- ) : (
-
+ {/* One container either way, so the starter list, its live region and
+ their focus survive the relay's support changing on a re-read. */}
+
+ {supported && (
- {starters}
- {status}
- {footer}
- {toast}
-
- )}
+ )}
+ {starters}
+ {status}
+ {footer}
+ {toast}
+
);
}
From 18653e26171e2a4a4ba1c78b573a2d77f87eac5b Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 22:46:01 +1100
Subject: [PATCH 17/28] fix(channels): rename the welcome opening's list-loaded
ref after rebase
origin/main (#2377) added a `listed` state to ChannelsPage for the context
hold. This branch's enrollment wait added a `listed` ref in the same
component, so the rebase produced a duplicate declaration that fails to
typecheck. Rename the branch's timestamp ref to `listedAt`.
Co-Authored-By: Claude Opus 5.5
Signed-off-by: Matt Toohey
---
src/bundled/channels/ChannelsPage.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 30c0368b61..18d08710ba 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -613,7 +613,7 @@ function ChannelWorkspace({
(channel) => channel.landing && channel.channelType === "stream",
);
/** When the list first loaded; the opening waits are measured from it. */
- const listed = useRef(undefined);
+ const listedAt = useRef(undefined);
// With nothing saved or requested, the relay's NIP-11 document names its
// welcome channel before the list can: a new member's list may not have it
// yet. A saved selection whose metadata is still arriving needs no read; one
@@ -670,7 +670,7 @@ function ChannelWorkspace({
() => setHeldOut(true),
Math.max(
0,
- (listed.current ?? Date.now()) + ENROLLMENT_WAIT_MS - Date.now(),
+ (listedAt.current ?? Date.now()) + ENROLLMENT_WAIT_MS - Date.now(),
),
);
return () => clearTimeout(timer);
@@ -755,7 +755,7 @@ function ChannelWorkspace({
}, []);
useEffect(() => settleDefault, [settleDefault]);
useLayoutEffect(() => {
- if (list.status === "ready") listed.current ??= Date.now();
+ if (list.status === "ready") listedAt.current ??= Date.now();
// The route handoff briefly passes no navigation; only another visit
// settles the choice.
if (
@@ -800,8 +800,8 @@ function ChannelWorkspace({
if (
currentId !== choice.channelId ||
welcomeId === choice.channelId ||
- listed.current === undefined ||
- Date.now() - listed.current >= ENROLLMENT_WAIT_MS ||
+ listedAt.current === undefined ||
+ Date.now() - listedAt.current >= ENROLLMENT_WAIT_MS ||
// Only the provisional visit while it is shown: never Settings over it,
// nor a visit opened since. An unrouted workspace has no visit to match,
// so only Settings is checked.
From 4f6169f144d09dd535f789137e38ac8e42c4136d Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Fri, 6 Nov 2026 22:52:01 +1100
Subject: [PATCH 18/28] fix(communities): style the welcome editor's link chips
as messages do
A channel link pasted into the welcome message showed as plain text,
while the card underlined it: the editor's link tokens rendered without
the conversation extensions, so the links plugin's renderer never
reached them. Inject the conversation into the moderation plugin again
and pass it through Welcome settings to the editor's tokens, so pasted
channel and message links present as they do in messages. The tokens
stay inert.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/communities.md | 5 ++-
src/bundled/moderation/README.md | 7 +--
.../moderation/WelcomeMessageEditor.tsx | 5 +++
.../moderation/WelcomeSettings.test.tsx | 45 +++++++++++++++++++
src/bundled/moderation/WelcomeSettings.tsx | 8 ++++
src/bundled/moderation/index.tsx | 6 ++-
src/bundled/moderation/registration.test.ts | 4 ++
7 files changed, 74 insertions(+), 6 deletions(-)
diff --git a/docs/communities.md b/docs/communities.md
index ace447f5d2..f7324ce6d1 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -200,8 +200,9 @@ the start or end of the message on each side, outside Markdown code and links.
Anywhere else, such as after `[@Name](`, an unclosed backtick or a URL being
typed, it stays as pasted.
Channel or message links pasted from **Copy link** show as chips in the editor,
-and as linked names in the card, where they open in place for members. It takes
-1–4,000 characters, with no control characters other than line breaks and tabs.
+with the same link presentation as in messages, and as linked names in the
+card, where they open in place for members. It takes 1–4,000 characters, with
+no control characters other than line breaks and tabs.
Both are checked against the relay's rules as the admin types: text made only
of spaces and invisible characters, such as zero-width spaces or the Braille
blank, counts as empty, and hidden text-direction marks, embeddings, overrides
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index aed48fce7c..2dce0d4117 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -21,9 +21,10 @@ for a possible return, but is hidden in the shipped UI.
On relays that support it, **Settings → Community admin → Welcome** lets an
owner or admin choose the public channel new members join and open first, and
the title and message shown at the start of that channel. The message editor
-has the composer's formatting toolbar and saves Markdown. Saving moves
-the welcome to the chosen channel; people already in the community keep their
-channels.
+has the composer's formatting toolbar and saves Markdown; pasted channel and
+message links show with the same link presentation as in messages, through
+the conversation's link renderers. Saving moves the welcome to the chosen
+channel; people already in the community keep their channels.
Below that, **Also add new members to** lists the starter channels, which new
members join too: **Add a channel** searches other open channels, and **Remove**
diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx
index d90d4241f8..feeb69fc9e 100644
--- a/src/bundled/moderation/WelcomeMessageEditor.tsx
+++ b/src/bundled/moderation/WelcomeMessageEditor.tsx
@@ -8,6 +8,7 @@ import {
type RefObject,
} from "react";
import { WelcomeMessage } from "../../features/communities/WelcomeCard";
+import type { ConversationExtensions } from "../../features/conversation/contracts";
import { ComposerFormattingTools } from "../../features/messages/ComposerFormattingTools";
import { ComposerLinkDialog } from "../../features/messages/ComposerLinkDialog";
import {
@@ -143,6 +144,7 @@ export function WelcomeMessageEditor({
session,
scope,
channelId,
+ extensions,
}: {
ref: RefObject;
label: string;
@@ -156,6 +158,8 @@ export function WelcomeMessageEditor({
scope: string;
/** Scopes the names identity chips show to this channel's members. */
channelId: string;
+ /** The link renderers messages use, so chips here look as the card's links do. */
+ extensions: ConversationExtensions | undefined;
}) {
const id = useId();
const [draft, setDraft] = useState(() => mentionDraft(value));
@@ -197,6 +201,7 @@ export function WelcomeMessageEditor({
session={session}
scope={scope}
channelId={channelId}
+ extensions={extensions}
onOpenLink={noOpen}
interactive={false}
/>
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index 79424651c4..aaf690575e 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -21,9 +21,14 @@ import type {
Membership,
} from "../../features/communities/service";
import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard";
+import type {
+ ConversationExtensions,
+ LinkRenderer,
+} from "../../features/conversation/contracts";
import type { ComposerInputElement } from "../../features/messages/composer-dom";
import { composerDOMFixture } from "../../features/messages/composer-testing";
import { profileTarget } from "../../features/profiles/target";
+import type { Contribution } from "../../plugins/contributions";
import {
ChannelLifecycleUnconfirmed,
type ChannelLanding,
@@ -92,6 +97,7 @@ function setup({
directory = rows,
names,
membership = { name: "Primary" },
+ extensions,
}: {
info?: CommunityInfo;
landing?: ChannelLanding | undefined;
@@ -102,6 +108,8 @@ function setup({
names?: Readonly>;
/** How the viewer's saved membership names the community. */
membership?: Omit;
+ /** The conversation's renderers, as the plugin passes them. */
+ extensions?: ConversationExtensions;
} = {}) {
const readLanding = vi.fn(
async () => landing,
@@ -202,6 +210,7 @@ function setup({
relay={relay}
communities={communities}
active={() => true}
+ extensions={extensions}
/>,
{ wrapper: ToastProvider },
);
@@ -304,6 +313,42 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
);
});
+// Link styling comes from the links plugin's renderer, which messages reach
+// through the conversation extensions. Without them, a pasted channel link
+// shows as plain `#name` text in the editor while the card underlines it.
+it("shows a pasted channel link through the conversation's link renderer, as the card does", async () => {
+ const renderer: Contribution = {
+ id: "links",
+ title: "Links",
+ key: "test/links",
+ pluginId: "test",
+ revision: "one",
+ matches: (url) => url.startsWith("buzz://channel/"),
+ className: "link-style",
+ component: ({ url }) => {url} ,
+ };
+ const subscribe = () => () => {};
+ setup({
+ extensions: {
+ links: { snapshot: () => [renderer], subscribe },
+ inline: { snapshot: () => [], subscribe },
+ tools: { snapshot: () => [], subscribe },
+ },
+ });
+ await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
+ pasteIntoMessage(`buzz://channel/${square}`);
+ const chip = await waitFor(() => {
+ const found = messageField().querySelector("[data-link-renderer]");
+ expect(found).not.toBeNull();
+ return found as HTMLElement;
+ });
+ // The chip only names the channel: no anchor, so nothing opens from the editor.
+ expect(chip.tagName).toBe("SPAN");
+ expect(chip).toHaveAttribute("data-link-renderer", "test/links");
+ expect(chip).toHaveClass("link-style");
+ expect(chip).toHaveTextContent(`buzz://channel/${square}`);
+});
+
/** A plain-text clipboard holding `text`. */
const plainClipboard = (text: string) => ({
clipboardData: {
diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx
index 2b62d93535..1c48aaba19 100644
--- a/src/bundled/moderation/WelcomeSettings.tsx
+++ b/src/bundled/moderation/WelcomeSettings.tsx
@@ -14,6 +14,7 @@ import {
type CommunityInfo,
} from "../../features/communities/api";
import type { CommunityReader } from "../../features/communities/service";
+import type { ConversationExtensions } from "../../features/conversation/contracts";
import {
DEFAULT_WELCOME_MESSAGE,
useDefaultWelcomeTitle,
@@ -114,11 +115,14 @@ export function WelcomeSettings({
relay,
communities,
active,
+ extensions,
}: {
relay: RelayData;
/** Names the community in the default title. */
communities?: CommunityReader | undefined;
active(): boolean;
+ /** Presents pasted links in the message editor as messages show them. */
+ extensions?: ConversationExtensions | undefined;
}) {
const connection = useRelayConnection(relay);
const community =
@@ -140,6 +144,7 @@ export function WelcomeSettings({
community={community}
communities={communities}
active={active}
+ extensions={extensions}
/>
) : (
Choose a connected community to set its welcome.
@@ -276,12 +281,14 @@ function WelcomeEditor({
community,
communities,
active,
+ extensions,
}: {
session: RelaySession;
scope: string;
community: string;
communities: CommunityReader | undefined;
active(): boolean;
+ extensions: ConversationExtensions | undefined;
}) {
const lifecycle = session.channelLifecycle;
const defaultTitle = useDefaultWelcomeTitle(communities, scope);
@@ -853,6 +860,7 @@ function WelcomeEditor({
session={session}
scope={scope}
channelId={channelId ?? ""}
+ extensions={extensions}
/>
{(normalized !== DEFAULT_WELCOME_MESSAGE || command.title) && (
diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx
index b6a55deac1..e9e602f098 100644
--- a/src/bundled/moderation/index.tsx
+++ b/src/bundled/moderation/index.tsx
@@ -9,6 +9,7 @@ export const inject = [
"adminAccess",
"navigation",
"communityReader",
+ "conversation",
];
export const apply: PluginModule["apply"] = (ctx) => {
const relay = ctx.relay;
@@ -69,7 +70,9 @@ export const apply: PluginModule["apply"] = (ctx) => {
),
});
// Owners and admins set the welcome and starter channels. The card itself
- // keeps the welcome form to relays that support it.
+ // keeps the welcome form to relays that support it. The message editor
+ // shows pasted links through the conversation's link renderers, as the
+ // card does.
ctx.settingsCards.register({
id: "welcome",
title: "Welcome",
@@ -84,6 +87,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
relay={relay}
communities={ctx.communityReader}
active={active}
+ extensions={ctx.conversation}
/>
),
});
diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts
index b5a92ceed3..136bc64eb3 100644
--- a/src/bundled/moderation/registration.test.ts
+++ b/src/bundled/moderation/registration.test.ts
@@ -69,6 +69,9 @@ it("registers the Membership card under the key the community rail opens", async
snapshot: () => ({ memberships: [] }),
subscribe: () => () => {},
} as unknown as CommunityReader);
+ // Its message editor shows pasted links through the conversation's
+ // link renderers.
+ root.provide("conversation", {} as never);
const access = createAdminAccess(
data,
{
@@ -132,6 +135,7 @@ it.each([
snapshot: () => ({ memberships: [] }),
subscribe: () => () => {},
} as unknown as CommunityReader);
+ root.provide("conversation", {} as never);
const access = createAdminAccess(
data,
{
From 0119931e285c208cd706d44612ee2360d4566f4d Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 00:51:25 +1100
Subject: [PATCH 19/28] feat(relay): admit one author-only read without kinds
The pinned welcome needs to ask whether the viewer has written anything
the relay can show them before this device first opened the community,
of any kind and in any channel. That is one filter with only authors,
until and a limit of 1, and both client gates refused it: the session
reader and the browser broker require kinds or event IDs on every read.
earlier-activity.ts builds that filter and recognizes exactly its shape
(one 64-hex author, a non-negative integer until, limit 1, no other
key). The reader and the broker admit it and still refuse every other
filter without kinds or IDs. The native host needs no route of its own:
its session reads already go through the signed POST /query bridge,
which forwards the filters unchanged.
The relay itself still refuses this filter today. Its p-gated check
(p_gated_filters_authorized in buzz-relay handlers/req.rs) treats a
filter without kinds as able to match gift wraps and member notices
unless it carries #p=[self], so /query answers 403 and a WebSocket REQ
is CLOSED as restricted. Until the relay exempts filters whose authors
are only the authenticated pubkey, the read fails, and the welcome's
failure rule keeps the card hidden.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
browser-host/relay-broker.mjs | 4 +++-
browser-host/relay-broker.test.mjs | 8 ++++++++
src/features/relay/earlier-activity.ts | 23 +++++++++++++++++++++++
src/features/relay/reader.test.ts | 20 ++++++++++++++++++++
src/features/relay/reader.ts | 4 +++-
5 files changed, 57 insertions(+), 2 deletions(-)
create mode 100644 src/features/relay/earlier-activity.ts
diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs
index bcf8aef59e..83dcef9147 100644
--- a/browser-host/relay-broker.mjs
+++ b/browser-host/relay-broker.mjs
@@ -1,4 +1,5 @@
import { isSidebarPreferencesBatch } from "../src/features/relay/sidebar-preferences.ts";
+import { isEarlierActivityFilter } from "../src/features/relay/earlier-activity.ts";
import {
isCatalogKind,
MAX_EVENT_BYTES as CATALOG_EVENT_BYTES,
@@ -768,7 +769,8 @@ export function validFilters(filters) {
)) ||
(filter.kinds === undefined &&
Array.isArray(filter.ids) &&
- filter.ids.length > 0)) &&
+ filter.ids.length > 0) ||
+ isEarlierActivityFilter(filter)) &&
Number.isInteger(filter.limit) &&
filter.limit >= 1 &&
filter.limit <= MAX_LIMIT,
diff --git a/browser-host/relay-broker.test.mjs b/browser-host/relay-broker.test.mjs
index ac3cacf050..fe592a9d20 100644
--- a/browser-host/relay-broker.test.mjs
+++ b/browser-host/relay-broker.test.mjs
@@ -14,6 +14,8 @@ test("read broker accepts non-channel finite reads without relaxing filter budge
{ ids: ["event"], limit: 1 },
{ kinds: [9], search: "design", limit: 20 },
{ kinds: [9], "#e": ["root"], depth_limit: 10, limit: 80 },
+ // The welcome card's one question: has the viewer written anything yet?
+ { authors: ["a".repeat(64)], until: 1788810000, limit: 1 },
])
assert.equal(validFilters([filter]), true);
for (const filters of [
@@ -25,6 +27,12 @@ test("read broker accepts non-channel finite reads without relaxing filter budge
[{ kinds: [65536], limit: 1 }],
[{ kinds: [], limit: 1 }],
[{ limit: 1 }],
+ // Only that exact author-only shape may omit kinds.
+ [{ authors: ["a".repeat(64)], limit: 1 }],
+ [{ authors: ["a".repeat(64)], until: 1788810000, limit: 2 }],
+ [{ authors: ["a".repeat(64), "b".repeat(64)], until: 1, limit: 1 }],
+ [{ authors: ["a".repeat(64)], until: 1, limit: 1, "#h": ["channel"] }],
+ [{ authors: ["viewer"], until: 1, limit: 1 }],
])
assert.equal(validFilters(filters), false);
});
diff --git a/src/features/relay/earlier-activity.ts b/src/features/relay/earlier-activity.ts
new file mode 100644
index 0000000000..5c8dd3b48e
--- /dev/null
+++ b/src/features/relay/earlier-activity.ts
@@ -0,0 +1,23 @@
+/** Whether the viewer wrote anything this relay can show them, of any kind and
+ * in any channel they can read, at or before `until` (unix seconds): one event
+ * answers the question. The only read without kinds or event IDs that the
+ * session reader and the browser broker admit. */
+export function earlierActivityFilter(viewer: string, until: number) {
+ return { authors: [viewer], until, limit: 1 };
+}
+
+/** Exactly the shape `earlierActivityFilter` builds; nothing more. */
+export function isEarlierActivityFilter(filter: unknown): boolean {
+ if (!filter || typeof filter !== "object") return false;
+ const { authors, until, limit, ...rest } = filter as Record;
+ return (
+ !Object.keys(rest).length &&
+ Array.isArray(authors) &&
+ authors.length === 1 &&
+ typeof authors[0] === "string" &&
+ /^[0-9a-f]{64}$/.test(authors[0]) &&
+ Number.isSafeInteger(until) &&
+ (until as number) >= 0 &&
+ limit === 1
+ );
+}
diff --git a/src/features/relay/reader.test.ts b/src/features/relay/reader.test.ts
index 5df1f8e027..f0c0f579e0 100644
--- a/src/features/relay/reader.test.ts
+++ b/src/features/relay/reader.test.ts
@@ -153,6 +153,26 @@ it("supports the desktop's ID, reference, address, and application-data reads wi
}
});
+it("admits the welcome card's author-only read without kinds, and no other", async () => {
+ const { read, next } = setup();
+ const viewer = "a".repeat(64);
+ const probe = { authors: [viewer], until: 1_788_810_000, limit: 1 };
+ const result = read([probe]);
+ const wire = next();
+ expect(wire.filters).toEqual([probe]);
+ wire.respond([]);
+ await expect(result).resolves.toEqual([]);
+ for (const refused of [
+ { authors: [viewer], limit: 1 },
+ { authors: [viewer], until: 1, limit: 2 },
+ { authors: [viewer], until: 1, limit: 1, "#h": ["channel"] },
+ { authors: [viewer, "b".repeat(64)], until: 1, limit: 1 },
+ ])
+ await expect(read([refused])).rejects.toThrow(
+ "Relay reads need valid kinds or event IDs",
+ );
+});
+
it("preserves ordered bridge extensions and distinguishes their request identity", async () => {
const { read, next, pending } = setup();
const first = read([
diff --git a/src/features/relay/reader.ts b/src/features/relay/reader.ts
index 785c63ffcb..d22be9d764 100644
--- a/src/features/relay/reader.ts
+++ b/src/features/relay/reader.ts
@@ -1,4 +1,5 @@
import { isSidebarPreferencesBatch } from "./sidebar-preferences";
+import { isEarlierActivityFilter } from "./earlier-activity";
import { isWorkflowDefinitionBatch } from "../workflows/queries";
import { verifyThreadWindows } from "./thread-window";
import { yieldToHost } from "./yield";
@@ -277,7 +278,8 @@ export function createRelayReader(
(kind) => Number.isInteger(kind) && kind >= 0 && kind <= 65535,
)
) &&
- !(filter.kinds === undefined && filter.ids?.length)
+ !(filter.kinds === undefined && filter.ids?.length) &&
+ !isEarlierActivityFilter(filter)
)
throw new Error("Relay reads need valid kinds or event IDs");
if (
From 24c0ae27e25d79c6964897d5f72ea65eb89352da Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 00:51:43 +1100
Subject: [PATCH 20/28] feat(communities): pin the welcome above the composer
for new members
The welcome card started the welcome channel's timeline, so in a busy
community it sat hundreds of messages up and a newcomer never saw it. It
is now pinned over the end of the history, directly above the composer,
in the slot a preview's join notice uses. It reports its height so the
history's end grows and the last messages scroll clear, a long message
scrolls within the card, and it never takes focus on mount. Dismiss
hides it and hands focus to the composer if focus was in the card.
It shows only to a member of the welcome channel who looks new. The
first time Messages opens a community on this device, or the first time
after upgrading, it records T in view state scoped by the community
origin alone (welcome-first-open), never replacing it. In the welcome
channel the client then asks the relay once whether the viewer wrote
anything from before T less ten minutes: one snapshot read of
{authors: [viewer], until: T - 600, limit: 1}, with no kinds and no #h,
so any kind in any readable channel counts. The ten minutes keep a
newcomer's own events from joining from counting. The answer is kept
per origin and viewer (welcome-earlier-activity), since a second
identity on the device must ask about itself. Dismissal is kept per
origin and channel (welcome-dismissed:), so an admin's later
edit does not bring the card back.
Nothing shows while the read is pending, and a failed read is not an
answer: nothing is kept and the next visit asks again, so an existing
member is never shown the card because of a network failure. Lurkers
with no events see it once per new device, dismissal is per device, and
lurkers already in an adopted welcome channel see it once per device
after the upgrade. Those trade-offs are accepted and documented.
ChannelBody no longer passes a start to the timeline. The timeline's
start prop stays, now without callers. Tests cover a new member, an
older event, an event inside the ten minutes with the exact filter, a
failed read, dismissal across a reload, and an admin edit after
dismissal. They were written but not run on this machine; tsc --noEmit
and Biome pass.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/buzz-help.md | 10 +-
docs/channels.md | 57 ++++-
docs/communities.md | 7 +-
src/bundled/channels/ChannelBody.tsx | 40 ---
.../channels/ChannelsPage.tabs.test.tsx | 90 ++++++-
src/bundled/channels/ChannelsPage.tsx | 125 +++++----
src/bundled/moderation/README.md | 8 +-
.../communities/WelcomeCard.module.css | 29 ++-
src/features/communities/WelcomeCard.test.tsx | 18 +-
src/features/communities/WelcomeCard.tsx | 95 ++++++-
src/features/communities/welcome-pin.test.tsx | 237 ++++++++++++++++++
src/features/communities/welcome-pin.ts | 125 +++++++++
12 files changed, 722 insertions(+), 119 deletions(-)
create mode 100644 src/features/communities/welcome-pin.test.tsx
create mode 100644 src/features/communities/welcome-pin.ts
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 00e255a3f0..e9389487d4 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -463,9 +463,13 @@ revokes all of them, and a pairing unused for 30 days expires.
the community's server, it names the account it refused and offers
**Switch account**. **Check again** once an admin has added you.
- **Welcome channel**: on relays that support it, a community can choose a
- public channel that new members join and open first. It starts with a
- welcome card for everyone, showing the community's message or a default one.
- The card isn't a message: it can't be replied to, deleted or dismissed. While
+ public channel that new members join and open first. There, a new member
+ sees a welcome card above the message box, showing the community's message
+ or a default one. Choose **Dismiss** to hide it on this computer; it stays
+ hidden even if the message changes. People who had already written in the
+ community never see it, though someone who has never posted may see it once
+ on each new device. The card isn't a message: it can't be replied to or
+ deleted. While
a new member's channels arrive, Messages says it is opening them, and waits a
few seconds for the welcome channel rather than open another conversation
first. If none arrive, **Try again** checks for them again; a welcome channel
diff --git a/docs/channels.md b/docs/channels.md
index fe737d54f8..594f504ccd 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1325,12 +1325,9 @@ arrives within ten seconds of the channel list first loading replaces it, unless
the viewer has clicked or typed anywhere in the window or opened anything else,
Settings included, meanwhile. Focus the viewer has moved outside Messages stays
where it is.
-The welcome channel's timeline starts with a
-welcome card for every viewer, even with no messages: the community name and its
-custom message, or the default one. The card is not a message, so it has no
-unread state, actions or dismissal. While the channel list or its metadata is
-still loading, Messages shows **Opening your channels…** instead of the blank
-selection view. A list with no channel to open keeps that state for up to ten
+A new member also sees the [pinned welcome](#pinned-welcome) there. While the
+channel list or its metadata is still loading, Messages shows **Opening your
+channels…** instead of the blank selection view. A list with no channel to open keeps that state for up to ten
seconds, once per community visit, since the relay can still be adding a new
member to the welcome channel; a channel that arrives meanwhile opens at once. A
returning member with no channels left waits the same once. A relay whose NIP-11
@@ -1360,6 +1357,54 @@ flag; it never sends a join on a member's behalf. The welcome channel is joined
only at a person's first entry, on either path: moving the designation never adds
anyone who already entered.
+### Pinned welcome
+
+The welcome is a card pinned over the end of the welcome channel's history,
+directly above the composer, in the place a preview's join notice takes. It is
+never a message or a row in the timeline, so it is there however long the
+history is, and an empty channel shows its usual empty notice beside it. The
+history's end grows by the card's height, as for the join notice, so the last
+messages scroll clear of it, and a long message scrolls within the card. The
+card is a region named by its title: the custom title, or the default ("Welcome
+to" the community's configured name, else "Welcome"). Below it, the custom
+message or the default one renders as a message body does, with its channel,
+message and identity links. It never takes focus on mount; **Dismiss** removes
+it and hands focus to the composer if focus was in the card.
+
+It shows only while all four of these hold:
+
+1. The open channel is the community's welcome channel (its relay-signed
+ `39000` carries `landing`), and the viewer is a member with its composer.
+2. This device has a first-open time T for the community: unix seconds, written
+ the first time Messages opens the community on this device, and on an
+ existing install the first time after upgrading. It is view state scoped by
+ the community origin alone, with no viewer, under `welcome-first-open`, and
+ is never replaced.
+3. The relay has no event by the viewer from before T less ten minutes. One
+ read, through the session's snapshot read (the HTTP `/query` bridge on both
+ hosts), asks `{ "authors": [viewer], "until": T - 600, "limit": 1 }`, with no
+ kinds and no `#h`, so any kind in any channel the relay lets the viewer read
+ counts, profiles and preferences included. An event means an existing member.
+ Because of the ten minutes, a newcomer's own events from joining, such as a
+ profile set up just before the app opened, do not count. The answer is kept
+ per device under `welcome-earlier-activity`, scoped by origin and viewer, so
+ another identity on the same device asks for itself. The session reader and
+ the browser broker admit no other filter without kinds or IDs.
+4. The card has not been dismissed on this device: `welcome-dismissed:`, scoped by origin alone. A new title or message from an admin does not
+ bring it back.
+
+Nothing shows while the read is pending. A read that fails, whether refused,
+timed out, offline or without a session, is no answer: nothing is kept and the
+next visit to the welcome channel asks again, so a network failure never shows
+the card to an existing member.
+
+These are accepted trade-offs. Someone with no events at all, such as a lurker,
+looks new on each new device and sees the card once there. Dismissal is per
+device, and shared by the identities on it, as T is. A lurker already in a
+channel that becomes the welcome channel, such as `#general`, sees the card
+once per device after the upgrade.
+
A cached roster can display previously downloaded, reverified history for up to
24 hours. It cannot authorize head/history reads, unread evidence, typing or
publishing. Unconfirmed membership is not resaved with a fresh lease. An identical
diff --git a/docs/communities.md b/docs/communities.md
index f7324ce6d1..e47d705657 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -170,9 +170,10 @@ community origin and viewer; channel IDs alone are not sufficient keys.
A relay that advertises the `buzz-landing-channel` NIP-11 extension keeps one
welcome channel per community. The relay adds each newly entering human to it
first, ahead of the starter channels. Messages opens it when there is no saved
-selection, waiting briefly for a designated channel that is not listed yet, and
-its timeline starts with a welcome card (see
-[local-first launch](channels.md#local-first-launch)).
+selection, waiting briefly for a designated channel that is not listed yet (see
+[local-first launch](channels.md#local-first-launch)). A new member sees a
+welcome card pinned above its composer until they dismiss it on that device;
+an existing member never does (see [pinned welcome](channels.md#pinned-welcome)).
Owners and admins set it under **Community admin → Welcome**, which appears
when the relay-signed roster gives the viewer an owner or admin role. Each time
diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx
index 70d96a32a0..593da82cc2 100644
--- a/src/bundled/channels/ChannelBody.tsx
+++ b/src/bundled/channels/ChannelBody.tsx
@@ -9,11 +9,6 @@ import { useChannelWindow } from "../../features/relay/react";
import { useRecoveryOwns } from "../../features/relay/RecoveryNotice";
import { headRecovering } from "../../features/relay/live-recovery";
import { clientMetrics } from "../../features/developer/client-metrics";
-import {
- CommunityWelcomeCard,
- WelcomeMessage,
-} from "../../features/communities/WelcomeCard";
-import type { CommunityReader } from "../../features/communities/service";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Channels.module.css";
@@ -28,20 +23,8 @@ export const ChannelBody = memo(function ChannelBody({
onOpenThread,
navigation,
onContentReady,
- landing,
- welcomeMessage,
- welcomeTitle,
- communities,
}: {
onContentReady?(channelId: string): void;
- /** The community's welcome channel: its timeline starts with a welcome. */
- landing?: boolean | undefined;
- /** Custom welcome message (Markdown source); absent shows the default. */
- welcomeMessage?: string | undefined;
- /** Custom welcome title; absent shows the default. */
- welcomeTitle?: string | undefined;
- /** Names the community in the welcome. */
- communities?: CommunityReader | undefined;
cached: boolean;
intro?: ReactNode;
viewer?: string | undefined;
@@ -60,11 +43,7 @@ export const ChannelBody = memo(function ChannelBody({
// The owner's Solo chat is a chat-style timeline that loads history on scroll.
const {
session: queries,
- scope,
channelId,
- extensions,
- onOpenLink,
- canOpenLink,
ownSoloConversation: solo,
} = useConversation();
// ChannelWorkspace already keys this lifetime by viewer/scope/generation.
@@ -160,25 +139,6 @@ export const ChannelBody = memo(function ChannelBody({
navigation={navigation}
chatPresentation={solo}
historyControl={solo ? "scroll" : "button"}
- start={
- landing && (
-
-
-
- )
- }
/>
);
});
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 082cf3a48b..35be385cc5 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -425,7 +425,7 @@ it("opens the community's auto-join channel first when nothing is saved", async
await screen.findByRole("textbox", { name: "Message #Beta" });
});
-it("opens the community's welcome channel before its auto-join channel and starts it with the welcome", async () => {
+it("opens the community's welcome channel before its auto-join channel and pins the welcome above the composer", async () => {
const viewer = keypair(),
authority = keypair();
workspace(
@@ -446,16 +446,88 @@ it("opens the community's welcome channel before its auto-join channel and start
viewer,
authority,
);
- await screen.findByRole("textbox", { name: "Message #Beta" });
+ const composer = await screen.findByRole("textbox", {
+ name: "Message #Beta",
+ });
+ // This viewer has written nothing on the relay, so they are new here.
const welcome = await screen.findByRole("region", {
name: "Hello, newcomer",
});
// The message is Markdown, rendered as a message body is.
expect(welcome).toHaveTextContent("Say hi and introduce yourself.");
expect(welcome.querySelector("strong")).toHaveTextContent("hi");
- // The welcome is not a message and replaces the empty-conversation notice.
- expect(welcome.closest("[data-message-id]")).toBeNull();
- expect(screen.queryByText("No messages yet.")).toBeNull();
+ // Pinned beside the history, not a row in it; an empty channel says so.
+ expect(welcome.closest("[data-message-scroller]")).toBeNull();
+ expect(await screen.findByText("No messages yet.")).toBeVisible();
+ // It never takes focus, and the history leaves room for it.
+ expect(welcome).not.toContainElement(document.activeElement as HTMLElement);
+ const conversation = composer.closest(
+ "[data-attachment-drop-zone]",
+ );
+ expect(
+ conversation?.style.getPropertyValue("--channel-preview-inset"),
+ ).toMatch(/^calc\(/);
+ await userEvent.click(
+ within(welcome).getByRole("button", { name: "Dismiss" }),
+ );
+ expect(screen.queryByRole("region", { name: "Hello, newcomer" })).toBeNull();
+ expect(conversation?.style.getPropertyValue("--channel-preview-inset")).toBe(
+ "0px",
+ );
+ // Dismiss leaves with the card, so focus goes on to the composer.
+ await vi.waitFor(() => expect(composer).toHaveFocus());
+});
+
+it("pins no welcome for a member who wrote on the relay before this device first opened it", async () => {
+ const viewer = keypair(),
+ authority = keypair();
+ const reads: (readonly ReadFilter[])[] = [];
+ workspace(
+ [
+ roster(authority, "beta", [viewer.pubkey]),
+ metadata(authority, "beta", "Beta", undefined, [
+ ["t", "stream"],
+ ["landing"],
+ ]),
+ signed(viewer, {
+ kind: 9,
+ content: "Hi all",
+ created_at: Math.floor(Date.now() / 1000) - 86_400,
+ tags: [["h", "beta"]],
+ }),
+ ],
+ viewer,
+ authority,
+ (filters) => {
+ reads.push(filters);
+ return undefined;
+ },
+ );
+ await screen.findByRole("textbox", { name: "Message #Beta" });
+ // One read over every kind and channel, for this viewer's events from
+ // before the first open less ten minutes.
+ await vi.waitFor(() =>
+ expect(
+ readView(
+ `https://relay.test:${viewer.pubkey}`,
+ "welcome-earlier-activity",
+ undefined,
+ ),
+ ).toBe(true),
+ );
+ const asked = reads.filter((filters) =>
+ filters.some((filter) => !filter.kinds && !filter.ids),
+ );
+ expect(asked).toHaveLength(1);
+ expect(asked[0]).toStrictEqual([
+ {
+ authors: [viewer.pubkey],
+ limit: 1,
+ until:
+ readView("https://relay.test", "welcome-first-open", 0) - 600,
+ },
+ ]);
+ expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull();
});
it("waits for the welcome channel's metadata before opening an auto-join channel listed first", async () => {
@@ -511,7 +583,7 @@ it("waits for the welcome channel's metadata before opening an auto-join channel
release();
}
await screen.findByRole("textbox", { name: "Message #Beta" });
- expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
});
@@ -650,7 +722,7 @@ it("opens the welcome channel as soon as it arrives while the opening state wait
);
owner.session.channels.refreshList?.();
await screen.findByRole("textbox", { name: "Message #Beta" });
- expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible();
expect(
screen.queryByRole("heading", { name: "You’re not in any channels yet" }),
).toBeNull();
@@ -732,7 +804,7 @@ it("waits for the welcome channel the relay names rather than open a DM, then op
);
owner.session.channels.refreshList?.();
await screen.findByRole("textbox", { name: "Message #Beta" });
- expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible();
});
it("opens a channel the person joined themselves at once, though the relay names an unlisted welcome channel", async () => {
@@ -989,7 +1061,7 @@ it("opens a welcome channel confirmed after the starter channel it opened first"
entry: { target: { kind: "conversation", channelId: "beta" } },
});
await screen.findByRole("textbox", { name: "Message #Beta" });
- expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull();
});
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 18d08710ba..008546ddf7 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -131,6 +131,14 @@ import { channelHasUnread } from "./useSidebarFilters";
import { inFieldOutsideComposer } from "./conversation-rows";
import styles from "./Channels.module.css";
import type { CommunityReader } from "../../features/communities/service";
+import {
+ PinnedWelcome,
+ WelcomeMessage,
+} from "../../features/communities/WelcomeCard";
+import {
+ recordWelcomeFirstOpen,
+ usePinnedWelcome,
+} from "../../features/communities/welcome-pin";
import type { CommunityInfo } from "../../features/communities/api";
import { communityFromScope } from "../../features/relay/gifs";
import { avatarMedia } from "../../shared/avatar-source";
@@ -436,7 +444,10 @@ function ChannelWorkspace({
// Start the optional catalog afterward so opening Messages cannot strand it.
if (list.status === "ready") queries.channelKit.ensure();
}, [queries, list.status]);
+ // The join notice's height, or the pinned welcome's: never both at once.
const [joinNoticeHeight, setJoinNoticeHeight] = useState(0);
+ // Before any passive effect, so the pinned welcome's first read has it.
+ useLayoutEffect(() => recordWelcomeFirstOpen(scope), [scope]);
const [contentReadyChannel, setContentReadyChannel] = useState();
const [composerFocus, setComposerFocus] = useState(0);
const [trackSoloMove, soloMoveNotices] = useSoloMoveRecovery(queries);
@@ -2266,6 +2277,18 @@ function ChannelWorkspace({
(current.archived ||
(current.readOnly &&
(!current.cached || current.viewerRole === "guest")));
+ const composes =
+ !!current &&
+ !current.archived &&
+ current.viewerRole !== "guest" &&
+ (!current.readOnly || !!current.cached);
+ const welcome = usePinnedWelcome({
+ session: queries,
+ scope,
+ channelId: current?.id,
+ // A member of the welcome channel, with its composer.
+ member: !!current?.landing && current.channelType === "stream" && composes,
+ });
useChannelHistoryFocus(
historyContainer,
navigation,
@@ -2304,10 +2327,6 @@ function ChannelWorkspace({
revealMessageId={
sent?.channelId === current.id ? sent.id : undefined
}
- landing={current.landing && current.channelType === "stream"}
- welcomeMessage={current.welcome}
- welcomeTitle={current.welcomeTitle}
- communities={communities}
/>
) : noChannels ? (
@@ -2368,6 +2387,30 @@ function ChannelWorkspace({
onHeightChange={setJoinNoticeHeight}
/>
)}
+ {current && welcome.shown && (
+
{
+ welcome.dismiss();
+ // Dismiss leaves with the card; typing is what comes next.
+ if (focused) setComposerFocus((value) => value + 1);
+ }}
+ onHeightChange={setJoinNoticeHeight}
+ >
+
+
+ )}
{!cached && (
)}
- {current &&
- !current.archived &&
- current.viewerRole !== "guest" &&
- (!current.readOnly || current.cached) && (
-
- ) : undefined
- }
- onSend={onComposerSend}
- onCommand={handleCommand}
- focusRequest={composerFocus}
- />
- )}
+ {current && composes && (
+
+ ) : undefined
+ }
+ onSend={onComposerSend}
+ onCommand={handleCommand}
+ focusRequest={composerFocus}
+ />
+ )}
);
const workspace = (
@@ -2478,9 +2518,10 @@ function ChannelWorkspace({
className={`${styles.conversation}${solo ? ` ${styles.soloConversation}` : ""}`}
style={
{
- "--channel-preview-inset": channelNotice
- ? `calc(${joinNoticeHeight}px + var(--space-3))`
- : "0px",
+ "--channel-preview-inset":
+ channelNotice || welcome.shown
+ ? `calc(${joinNoticeHeight}px + var(--space-3))`
+ : "0px",
} as React.CSSProperties
}
data-attachment-drop-zone=""
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 2dce0d4117..81fee55941 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -20,11 +20,13 @@ for a possible return, but is hidden in the shipped UI.
On relays that support it, **Settings → Community admin → Welcome** lets an
owner or admin choose the public channel new members join and open first, and
-the title and message shown at the start of that channel. The message editor
-has the composer's formatting toolbar and saves Markdown; pasted channel and
+the title and message of the welcome card pinned above that channel's
+composer for new members. The message editor has the composer's formatting
+toolbar and saves Markdown; pasted channel and
message links show with the same link presentation as in messages, through
the conversation's link renderers. Saving moves the welcome to the chosen
-channel; people already in the community keep their channels.
+channel; people already in the community keep their channels. Editing the
+welcome does not show the card again to anyone who dismissed it.
Below that, **Also add new members to** lists the starter channels, which new
members join too: **Add a channel** searches other open channels, and **Remove**
diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css
index c88a4edf14..81edcc85cd 100644
--- a/src/features/communities/WelcomeCard.module.css
+++ b/src/features/communities/WelcomeCard.module.css
@@ -1,15 +1,38 @@
+/* Floats over the end of the history, as a channel's join notice does; the
+ page extends the history's end so its last messages scroll clear. */
+.pinned {
+ position: absolute;
+ inset-inline: 0;
+ inset-block-end: 0;
+ z-index: 10;
+ display: flex;
+ justify-content: center;
+ padding: var(--space-3);
+ pointer-events: none;
+}
.card {
- background: var(--surface-inset);
- border-radius: var(--radius-container);
- padding: var(--space-4) var(--space-5);
+ @apply floating-surface;
+ pointer-events: auto;
+ width: min(100%, 40rem);
+ padding: var(--space-3) var(--space-4) var(--space-4);
color: var(--text-standard);
text-align: start;
}
+.header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
.title {
+ flex: 1;
+ min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
+/* A long welcome scrolls within the card instead of covering the history. */
.body {
+ max-block-size: min(12rem, 30vh);
+ overflow-y: auto;
margin-block-start: var(--space-1);
min-width: 0;
overflow-wrap: anywhere;
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
index a5abcc7561..78a1757505 100644
--- a/src/features/communities/WelcomeCard.test.tsx
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -2,7 +2,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { profileTarget } from "../profiles/target";
import type { RelaySession } from "../relay/session";
import type { CommunityReader } from "./service";
@@ -13,7 +13,21 @@ import {
WelcomeMessage,
} from "./WelcomeCard";
-afterEach(cleanup);
+beforeEach(() => {
+ // jsdom has no ResizeObserver; the card measures whether its body scrolls.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
const channel = "11111111-1111-4111-8111-111111111111";
const scope = `https://primary.example:${"0".repeat(64)}`;
diff --git a/src/features/communities/WelcomeCard.tsx b/src/features/communities/WelcomeCard.tsx
index 8931fbc3a8..b8b78ccbb2 100644
--- a/src/features/communities/WelcomeCard.tsx
+++ b/src/features/communities/WelcomeCard.tsx
@@ -1,4 +1,11 @@
-import { useId, useSyncExternalStore, type ReactNode } from "react";
+import {
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
import type { ConversationExtensions } from "../conversation/contracts";
import { MessageMarkdown } from "../messages/MessageMarkdown";
import { useReferenceDirectory } from "../messages/ReferenceText";
@@ -7,6 +14,7 @@ import type { RelaySession } from "../relay/session";
import { communityPresentation } from "./community-icon";
import { communityDestination } from "./destination";
import type { CommunityReader } from "./service";
+import { Button } from "../../shared/design-system/ui/Button";
import styles from "./WelcomeCard.module.css";
/** Shown when the community has not set its own welcome message. */
@@ -46,25 +54,62 @@ export function useDefaultWelcomeTitle(
return defaultWelcomeTitle(name);
}
-/** The welcome channel's introduction; a timeline start, never a message. */
+/** The welcome channel's introduction, never a message. A long message
+ * scrolls within the card, which is then reachable by keyboard. */
export function WelcomeCard({
title,
children,
level = 2,
+ onDismiss,
}: {
title: string;
/** The rendered message, usually a `WelcomeMessage`. */
children: ReactNode;
level?: 2 | 3 | 4;
+ /** Offers Dismiss; `focused` says whether focus was in the card. */
+ onDismiss?: ((focused: boolean) => void) | undefined;
}) {
const id = useId();
const Heading = `h${level}` as "h2" | "h3" | "h4";
+ const card = useRef(null);
+ const body = useRef(null);
+ const content = useRef(null);
+ const [scrolls, setScrolls] = useState(false);
+ useLayoutEffect(() => {
+ const scroller = body.current;
+ if (!scroller || !content.current) return;
+ const measure = () =>
+ setScrolls(scroller.scrollHeight > scroller.clientHeight);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(scroller);
+ observer.observe(content.current);
+ return () => observer.disconnect();
+ }, []);
return (
-
-
- {title}
-
- {children}
+
+
+
+ {title}
+
+ {onDismiss && (
+
+ onDismiss(!!card.current?.contains(document.activeElement))
+ }
+ >
+ Dismiss
+
+ )}
+
+
);
}
@@ -131,6 +176,7 @@ export function CommunityWelcomeCard({
scope,
title,
children,
+ onDismiss,
}: {
communities?: CommunityReader | undefined;
/** The relay session's partition. */
@@ -138,7 +184,40 @@ export function CommunityWelcomeCard({
/** Custom title; absent shows the default. */
title?: string | undefined;
children: ReactNode;
+ onDismiss?: ((focused: boolean) => void) | undefined;
}) {
const defaultTitle = useDefaultWelcomeTitle(communities, scope);
- return {children} ;
+ return (
+
+ {children}
+
+ );
+}
+
+/** The welcome card pinned over the end of the welcome channel's history,
+ * above the composer, as a channel's join notice is. It reports its height so
+ * the history can leave the last messages clear of it, and never takes focus. */
+export function PinnedWelcome({
+ onHeightChange,
+ ...card
+}: Parameters[0] & {
+ onDismiss(focused: boolean): void;
+ onHeightChange(height: number): void;
+}) {
+ const pinned = useRef(null);
+ useLayoutEffect(() => {
+ const element = pinned.current;
+ if (!element) return;
+ const measure = () =>
+ onHeightChange(Math.ceil(element.getBoundingClientRect().height));
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [onHeightChange]);
+ return (
+
+
+
+ );
}
diff --git a/src/features/communities/welcome-pin.test.tsx b/src/features/communities/welcome-pin.test.tsx
new file mode 100644
index 0000000000..aad71ddd1c
--- /dev/null
+++ b/src/features/communities/welcome-pin.test.tsx
@@ -0,0 +1,237 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { SnapshotRead } from "../agents/agent-config-artifacts";
+import type { ReadFilter, RelayEvent } from "../relay/events";
+import { matchesEvent } from "../relay/projection";
+import { readView, viewRevision, writeView } from "../../shared/view-state";
+import {
+ recordWelcomeFirstOpen,
+ usePinnedWelcome,
+ WELCOME_EARLIER_ACTIVITY_KEY,
+ WELCOME_FIRST_OPEN_KEY,
+ welcomeDismissedKey,
+} from "./welcome-pin";
+import { PinnedWelcome } from "./WelcomeCard";
+
+const origin = "https://primary.example";
+const me = "a".repeat(64);
+const other = "b".repeat(64);
+const scope = `${origin}:${me}`;
+const channel = "11111111-1111-4111-8111-111111111111";
+/** When this device first opened the community. */
+const T = 1_788_810_000;
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+});
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ vi.unstubAllGlobals();
+});
+
+const authored = (pubkey: string, createdAt: number) =>
+ ({
+ id: createdAt.toString(16).padStart(64, "0"),
+ pubkey,
+ created_at: createdAt,
+ kind: 9,
+ content: "hello",
+ tags: [["h", "general"]],
+ }) as unknown as RelayEvent;
+
+/** A relay holding `events`. Each read waits until the test answers it, with
+ * what the relay would return for its filters, or with a failure. */
+function fakeRelay(events: RelayEvent[] = []) {
+ const reads: {
+ filters: readonly ReadFilter[];
+ answer(): Promise;
+ fail(): Promise;
+ }[] = [];
+ const snapshotRead: SnapshotRead = (filters) =>
+ new Promise((resolve, reject) => {
+ reads.push({
+ filters,
+ answer: () =>
+ act(async () => {
+ resolve(
+ events.filter((event) =>
+ filters.some((filter) => matchesEvent(event, filter)),
+ ),
+ );
+ await Promise.resolve();
+ }),
+ fail: () =>
+ act(async () => {
+ reject(new Error("Relay is disconnected"));
+ await Promise.resolve();
+ }),
+ });
+ });
+ return { reads, snapshotRead };
+}
+
+/** The welcome channel's slot as Messages composes it. */
+function Slot({
+ relay,
+ viewer = me,
+ title,
+ message = "Say hi",
+}: {
+ relay: ReturnType | undefined;
+ viewer?: string;
+ title?: string;
+ message?: string;
+}) {
+ const welcome = usePinnedWelcome({
+ session: { viewer, snapshotRead: relay?.snapshotRead },
+ scope: `${origin}:${viewer}`,
+ channelId: channel,
+ member: true,
+ });
+ return welcome.shown ? (
+ welcome.dismiss()}
+ onHeightChange={() => {}}
+ >
+ {message}
+
+ ) : null;
+}
+
+it("records this device's first open once per relay, for every identity", () => {
+ recordWelcomeFirstOpen(scope, T * 1000 + 999);
+ recordWelcomeFirstOpen(`${origin}:${other}`, (T + 60) * 1000);
+ // Scoped by relay alone, and never replaced.
+ expect(readView(origin, WELCOME_FIRST_OPEN_KEY, undefined)).toBe(T);
+ expect(viewRevision(scope, WELCOME_FIRST_OPEN_KEY)).toBeNull();
+});
+
+it("shows a new member the welcome once the relay has nothing earlier by them", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ // Nothing shows while the answer is pending.
+ expect(screen.queryByRole("region")).toBeNull();
+ await relay.reads[0]?.answer();
+ const card = screen.getByRole("region", { name: "Hello, newcomer" });
+ expect(card).toHaveTextContent("Say hi");
+ expect(card).toContainElement(
+ screen.getByRole("button", { name: "Dismiss" }),
+ );
+ expect(card).not.toContainElement(document.activeElement as HTMLElement);
+ expect(readView(scope, WELCOME_EARLIER_ACTIVITY_KEY, undefined)).toBe(false);
+});
+
+it("never shows the welcome to a member with an older event, and asks only once", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay([authored(me, T - 86_400)]);
+ const { unmount } = render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ await relay.reads[0]?.answer();
+ expect(readView(scope, WELCOME_EARLIER_ACTIVITY_KEY, undefined)).toBe(true);
+ expect(screen.queryByRole("region")).toBeNull();
+ unmount();
+ // The answer is kept for this relay and viewer on this device.
+ render( );
+ await act(async () => {});
+ expect(relay.reads).toHaveLength(1);
+ expect(screen.queryByRole("region")).toBeNull();
+ cleanup();
+ // Another identity on this device asks about itself.
+ render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(2));
+ expect(relay.reads[1]?.filters).toStrictEqual([
+ { authors: [other], until: T - 600, limit: 1 },
+ ]);
+});
+
+it("counts an event from the ten minutes before the first open as new, with one kindless read", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay([authored(me, T - 300)]);
+ render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ // Any kind, in any channel the relay lets this viewer read: no kinds, no #h.
+ expect(relay.reads[0]?.filters).toStrictEqual([
+ { authors: [me], until: T - 600, limit: 1 },
+ ]);
+ await relay.reads[0]?.answer();
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+});
+
+it("keeps nothing from a failed read and asks again on the next open", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ const { unmount } = render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ await relay.reads[0]?.fail();
+ expect(screen.queryByRole("region")).toBeNull();
+ expect(viewRevision(scope, WELCOME_EARLIER_ACTIVITY_KEY)).toBeNull();
+ unmount();
+ render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(2));
+ await relay.reads[1]?.answer();
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+});
+
+it("asks nothing without a session or a recorded first open", async () => {
+ const relay = fakeRelay();
+ render( );
+ await act(async () => {});
+ expect(relay.reads).toHaveLength(0);
+ cleanup();
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ render( );
+ await act(async () => {});
+ expect(screen.queryByRole("region")).toBeNull();
+ expect(viewRevision(scope, WELCOME_EARLIER_ACTIVITY_KEY)).toBeNull();
+});
+
+it("stays dismissed on this device after a reload", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ const { unmount } = render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ await relay.reads[0]?.answer();
+ await userEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByRole("region")).toBeNull();
+ // Scoped by relay alone and keyed by the channel.
+ expect(readView(origin, welcomeDismissedKey(channel), undefined)).toBe(true);
+ unmount();
+ render( );
+ await act(async () => {});
+ expect(screen.queryByRole("region")).toBeNull();
+ expect(relay.reads).toHaveLength(1);
+});
+
+it("stays dismissed when an admin changes the welcome afterwards", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ const { rerender, unmount } = render(
+ ,
+ );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ await relay.reads[0]?.answer();
+ await userEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ rerender( );
+ await act(async () => {});
+ expect(screen.queryByRole("region")).toBeNull();
+ unmount();
+ render( );
+ await act(async () => {});
+ expect(screen.queryByRole("region")).toBeNull();
+ expect(screen.queryByText("Edited")).toBeNull();
+});
diff --git a/src/features/communities/welcome-pin.ts b/src/features/communities/welcome-pin.ts
new file mode 100644
index 0000000000..3a8a833ded
--- /dev/null
+++ b/src/features/communities/welcome-pin.ts
@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
+import type { SnapshotRead } from "../agents/agent-config-artifacts";
+import { earlierActivityFilter } from "../relay/earlier-activity";
+import { splitPartition } from "../relay/partition";
+import {
+ readView,
+ replaceView,
+ subscribeView,
+ writeView,
+} from "../../shared/view-state";
+
+/** A newcomer's own events from the minutes before this device first opened
+ * the community, such as a profile set up while joining, still count as new. */
+export const WELCOME_GRACE_SECONDS = 600;
+/** View key, scoped by relay alone (every identity on this device): unix
+ * seconds when this device first opened the community in Messages. */
+export const WELCOME_FIRST_OPEN_KEY = "welcome-first-open";
+/** View key, scoped by relay and viewer: whether the relay had an event by the
+ * viewer from before {@link WELCOME_FIRST_OPEN_KEY} less the grace period. */
+export const WELCOME_EARLIER_ACTIVITY_KEY = "welcome-earlier-activity";
+/** View key, scoped by relay alone: this welcome channel's card was dismissed. */
+export const welcomeDismissedKey = (channelId: string) =>
+ `welcome-dismissed:${channelId}`;
+
+const origin = (scope: string) => splitPartition(scope)?.communityOrigin;
+const unscoped = () => () => {};
+
+/** Records when this device first opened the community of the relay session
+ * `scope`, unless it has before. An existing install records its first open
+ * after upgrading. */
+export function recordWelcomeFirstOpen(scope: string, now = Date.now()) {
+ const relay = origin(scope);
+ if (relay)
+ replaceView(relay, WELCOME_FIRST_OPEN_KEY, null, Math.floor(now / 1000));
+}
+
+/**
+ * Whether the welcome channel `channelId` shows its pinned welcome to this
+ * viewer, who must be a member of it (`member`): only while this device has a
+ * first-open time for the community, the card has not been dismissed here, and
+ * the relay has no event by the viewer from before that time less
+ * {@link WELCOME_GRACE_SECONDS}. One read answers that, once per relay and
+ * viewer on this device. Nothing shows while it is pending, and a failed read
+ * is not an answer: nothing is kept and the next visit asks again.
+ */
+export function usePinnedWelcome({
+ session,
+ scope,
+ channelId,
+ member,
+}: {
+ session: Readonly<{
+ viewer?: string | undefined;
+ snapshotRead?: SnapshotRead | undefined;
+ }>;
+ /** The relay session's partition. */
+ scope: string;
+ channelId: string | undefined;
+ member: boolean;
+}) {
+ const relay = origin(scope);
+ const { viewer, snapshotRead: read } = session;
+ const dismissKey = channelId && welcomeDismissedKey(channelId);
+ const dismissed = useSyncExternalStore(
+ useCallback(
+ (listener: () => void) =>
+ relay ? subscribeView(relay, listener) : unscoped(),
+ [relay],
+ ),
+ // Messages renders on every list change; read storage only when it counts.
+ () =>
+ member &&
+ !!relay &&
+ !!dismissKey &&
+ readView(relay, dismissKey, false) === true,
+ );
+ // Hides at once even if storage cannot keep the dismissal.
+ const [closed, setClosed] = useState();
+ const [answer, setAnswer] = useState<{ scope: string; earlier: boolean }>();
+ const asking =
+ member && !!channelId && !dismissed && closed !== channelId && !!relay;
+ useEffect(() => {
+ if (!asking || !relay || !viewer) return;
+ const cached = readView(
+ scope,
+ WELCOME_EARLIER_ACTIVITY_KEY,
+ undefined,
+ );
+ if (typeof cached === "boolean") {
+ setAnswer({ scope, earlier: cached });
+ return;
+ }
+ const firstOpen = readView(relay, WELCOME_FIRST_OPEN_KEY, null);
+ if (
+ !read ||
+ typeof firstOpen !== "number" ||
+ !Number.isSafeInteger(firstOpen)
+ )
+ return;
+ const controller = new AbortController();
+ read([earlierActivityFilter(viewer, firstOpen - WELCOME_GRACE_SECONDS)], {
+ signal: controller.signal,
+ }).then(
+ (events) => {
+ if (controller.signal.aborted) return;
+ const earlier = events.length > 0;
+ writeView(scope, WELCOME_EARLIER_ACTIVITY_KEY, earlier);
+ setAnswer({ scope, earlier });
+ },
+ () => {
+ // Offline, refused or timed out proves nothing about this member.
+ },
+ );
+ return () => controller.abort();
+ }, [asking, relay, scope, viewer, read]);
+ const dismiss = useCallback(() => {
+ if (!relay || !dismissKey || !channelId) return;
+ setClosed(channelId);
+ writeView(relay, dismissKey, true);
+ }, [relay, dismissKey, channelId]);
+ return {
+ shown: asking && answer?.scope === scope && !answer.earlier,
+ dismiss,
+ };
+}
From ee4c15eeac142e53c21b43efc4826e136e113c00 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 01:27:12 +1100
Subject: [PATCH 21/28] refactor(messages): restore the timeline to main now
that the welcome is pinned
The welcome card used to start the welcome channel's timeline, and the
timeline grew a start slot for it. The card is now pinned above the
composer and ChannelBody no longer passes a start, so the slot has no
caller. Everything this branch added to the timeline for it goes back to
main's content, file by file:
- ChannelTimeline.tsx: the `start` prop and `atStart`, the start content
rendered in the measured edge and its `data-start` attribute, the
empty-conversation guard on `atStart`, and the edge-delta block in
measure(). That block was not gated on `start`: for any settled reader
who was not following it corrected scrollTop and rewrote the saved
position whenever the edge changed height, and the edge also holds the
error alert, the history-limit notice and the Load older control, so
ordinary channels scrolled differently from main for nothing.
- ChannelTimeline.test.tsx: the start-slot setup (edge geometry, `edge()`,
`hasStart()`, the row geometry that followed the offset) and its three
cases.
- ChannelTimeline.viewport.test.tsx: the start-content case.
- Messages.module.css: the `.edge[data-start]` rule.
None of these files had changed on origin/main since the merge base, so
each is byte-identical to main again.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
.../messages/ChannelTimeline.test.tsx | 110 +++---------------
src/features/messages/ChannelTimeline.tsx | 27 -----
.../ChannelTimeline.viewport.test.tsx | 29 -----
src/features/messages/Messages.module.css | 6 -
4 files changed, 14 insertions(+), 158 deletions(-)
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index 4b1a81f625..f8ca1913f8 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -1,6 +1,6 @@
import { Button } from "../../shared/design-system/ui/Button";
import { afterEach, expect, it, vi } from "vitest";
-import type { ReactElement, ReactNode } from "react";
+import type { ReactElement } from "react";
import { Virtualizer } from "virtua";
import { ChannelTimeline } from "./ChannelTimeline";
import { JumpToLatestButton } from "./JumpToLatestButton";
@@ -139,7 +139,6 @@ function setup({
initial = undefined as unknown,
mounted = [] as { id: string; y: number }[],
initialRows = undefined as ChannelMessage[] | undefined,
- start = undefined as ReactNode,
} = {}) {
Object.assign(hooks, {
refs: [],
@@ -218,14 +217,6 @@ function setup({
]);
}
};
- // The measured edge above the rows: its height is Virtua's start margin.
- let edgeHeight = 0;
- const edgeElement = {
- get clientWidth() {
- return element.clientWidth;
- },
- getBoundingClientRect: () => ({ height: edgeHeight }),
- };
const element = {
clientWidth: 1124,
clientHeight: 668,
@@ -237,16 +228,12 @@ function setup({
focus: vi.fn(),
getBoundingClientRect: () => ({ top: 0 }),
querySelector: () => list,
- // Row geometry reads `y` when asked, so a test can move the rows.
querySelectorAll: () =>
- mounted.map((row) => ({
- dataset: { messageId: row.id },
- getBoundingClientRect: () => ({ top: row.y, bottom: row.y + 100 }),
+ mounted.map(({ id, y }) => ({
+ dataset: { messageId: id },
+ getBoundingClientRect: () => ({ top: y, bottom: y + 100 }),
querySelector: () => ({
- getBoundingClientRect: () => ({
- top: row.y + 32,
- bottom: row.y + 80,
- }),
+ getBoundingClientRect: () => ({ top: y + 32, bottom: y + 80 }),
}),
})),
};
@@ -341,7 +328,6 @@ function setup({
},
onOpenLink: () => false,
revealMessageId,
- start,
});
if (key !== null && key !== scoped.key) {
for (const effect of hooks.effects) effect.cleanup?.();
@@ -358,9 +344,15 @@ function setup({
scoped.props,
);
section.props.ref.current = element;
- (
- section.props.children[0] as ReactElement<{ ref: { current: unknown } }>
- ).props.ref.current = edgeElement;
+ const edge = section.props.children[0] as ReactElement<{
+ ref: { current: unknown };
+ }>;
+ edge.props.ref.current = {
+ get clientWidth() {
+ return element.clientWidth;
+ },
+ getBoundingClientRect: () => ({ height: 0 }),
+ };
const virtualizer = section.props.children.find(
(child) =>
!!child &&
@@ -478,18 +470,6 @@ function setup({
resized(element);
render(runFrames);
},
- /** The edge laid out at a new height, as start content enters or leaves. */
- edge(height: number, runFrames = true) {
- edgeHeight = height;
- resized(edgeElement);
- render(runFrames);
- },
- hasStart() {
- const edge = section.props.children[0] as ReactElement<{
- children: unknown[];
- }>;
- return edge.props.children.includes(start);
- },
measureRows(runFrames = true) {
list.style.height = `${Number.parseFloat(list.style.height) + 100}px`;
styleChanged();
@@ -727,68 +707,6 @@ it.each([
expect(h.loadOlder).not.toHaveBeenCalled();
h.unmount();
});
-it("shows the start content only once the oldest history is loaded", () => {
- const h = setup({ hasMore: true, start: "Welcome" });
- expect(h.hasStart()).toBe(false);
- h.update({ hasMore: false });
- expect(h.hasStart()).toBe(true);
- h.update({ hasMore: true, loadingOlder: false });
- expect(h.hasStart()).toBe(false);
- h.unmount();
-});
-it("keeps a settled reader's rows in place as start content enters and leaves the edge", () => {
- const row = { id: "first", y: 100 };
- const h = setup({ hasMore: true, start: "Welcome", mounted: [row] });
- // The rows' viewport geometry follows the scroll offset, as in a browser.
- let offset = 1200;
- Object.defineProperty(h.element, "scrollTop", {
- configurable: true,
- get: () => offset,
- set(value: number) {
- row.y -= value - offset;
- offset = value;
- },
- });
- h.scroll();
- h.gesture(true);
- // The last older page lands and the welcome grows the edge above the rows,
- // which moves them down before the shift's scroll event records a place.
- h.update({ hasMore: false });
- row.y += 140;
- h.dispatchScroll();
- h.handle.scrollToIndex.mockClear();
- h.edge(140);
- expect(h.element.scrollTop).toBe(1340);
- expect(row.y).toBe(100);
- // The restore that follows the size change returns to the corrected place,
- // not to the row's position after the edge had pushed it down.
- expect(h.handle.scrollToIndex).toHaveBeenCalledWith(0, {
- align: "start",
- offset: -100,
- });
- for (const [, options] of h.handle.scrollToIndex.mock.calls)
- expect(options).toEqual({ align: "start", offset: -100 });
- // Trimming the oldest page reopens history and the welcome leaves again.
- h.update({ hasMore: true });
- row.y -= 140;
- h.dispatchScroll();
- h.handle.scrollToIndex.mockClear();
- h.edge(0);
- expect(h.element.scrollTop).toBe(1200);
- expect(row.y).toBe(100);
- for (const [, options] of h.handle.scrollToIndex.mock.calls)
- expect(options).toEqual({ align: "start", offset: -100 });
- h.unmount();
-});
-it("leaves a following reader at the bottom when start content enters the edge", () => {
- const h = setup({ hasMore: true, start: "Welcome" });
- h.scroll(false);
- const before = h.element.scrollTop;
- h.update({ hasMore: false });
- h.edge(140);
- expect(h.element.scrollTop).toBe(before);
- h.unmount();
-});
it("programmatic scroll does not page before a user gesture", () => {
const h = setup({ hasMore: true });
h.scroll(false);
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index 576870f6ed..e8971b02dc 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -190,9 +190,6 @@ export type ChannelTimelineProps = {
chatPresentation?: boolean | undefined;
/** False: rows open existing threads but offer none to start; see MessageRow. */
startsThreads?: boolean | undefined;
- /** Shown above the first message once all history is loaded; it is not a
- * message and replaces the empty-conversation notice. */
- start?: ReactNode;
onOpenLink(url: string, origin?: MessageLinkOrigin): boolean;
canOpenLink?: ((target: string) => boolean) | undefined;
revealMessageId?: string | undefined;
@@ -254,7 +251,6 @@ function Timeline({
onOpenMediaReview,
chatPresentation = false,
startsThreads = true,
- start,
}: ChannelTimelineProps) {
useMessageSelectionCopy();
const shownIntro =
@@ -764,12 +760,6 @@ function Timeline({
window.error)
)
setReserveHistorySpace(true);
- // The measured edge carries the start content, so Virtua offsets rows below it.
- const atStart =
- !!start &&
- window.status === "ready" &&
- !window.hasMore &&
- !window.historyLimited;
const handle = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0, edgeHeight: 0 });
const width = size.width;
@@ -1393,20 +1383,6 @@ function Timeline({
next.edgeHeight === measuredSize.edgeHeight
)
return;
- // Start content entering or leaving the edge moves every row below it
- // before Virtua learns the new start margin. Keep a settled reader's
- // rows where they were; a following reader is pinned to the bottom.
- const edgeDelta = next.edgeHeight - measuredSize.edgeHeight;
- if (edgeDelta && settled.current && !follow.current) {
- correctScrollTop(element, edgeDelta);
- // The restore this size change schedules must return here, not to
- // the place a scroll event recorded after the edge had moved the rows.
- if (
- !restoredAnchor.current &&
- pendingRestore.current !== intent.current
- )
- savedPosition.current = { ...positionAt(element), bottom: false };
- }
// A growing composer shrinks the viewport. Pin a reader who was at the
// bottom before paint so history rises with it; the restore lands later.
if (
@@ -1931,11 +1907,9 @@ function Timeline({
className={styles.edge}
data-history-space={reserveHistorySpace || undefined}
data-intro={!!shownIntro || undefined}
- data-start={atStart || undefined}
>
{shownIntro}
{edgeError && {edgeError} }
- {atStart && start}
{window.historyLimited ? (
History window limit reached
) : window.hasMore && queries.channels.cachedOnly ? (
@@ -2178,7 +2152,6 @@ function Timeline({
{!rows.length &&
!shownIntro &&
- !atStart &&
!window.hasMore &&
window.status === "ready" &&
!window.error &&
diff --git a/src/features/messages/ChannelTimeline.viewport.test.tsx b/src/features/messages/ChannelTimeline.viewport.test.tsx
index 86c1220e55..e60d9a983a 100644
--- a/src/features/messages/ChannelTimeline.viewport.test.tsx
+++ b/src/features/messages/ChannelTimeline.viewport.test.tsx
@@ -242,35 +242,6 @@ it("shows one latest-agent preview, retains expanded history through appends, an
}
});
-it("renders the start content in the edge only once there is no older history", () => {
- const { owner, updates, content } = activityFixture();
- try {
- const timeline = (hasMore: boolean) =>
- cloneElement(content(updates(2)), {
- start: Welcome aboard
,
- window: {
- channelId: "alpha",
- rows: updates(2),
- status: "ready",
- hasMore,
- loadingOlder: false,
- freshness: "verified",
- error: undefined,
- },
- });
- const view = render(timeline(true));
- expect(screen.queryByText("Welcome aboard")).toBeNull();
- view.rerender(timeline(false));
- const welcome = screen.getByText("Welcome aboard");
- expect(welcome).toBeVisible();
- expect(welcome.closest("[data-start]")).not.toBeNull();
- view.rerender(timeline(true));
- expect(screen.queryByText("Welcome aboard")).toBeNull();
- } finally {
- owner.dispose();
- }
-});
-
it("shows the first three distinct agents in the visible identity cluster while retaining its full label", () => {
const { owner, updates, content } = activityFixture();
try {
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index b1f17c4fec..040c737773 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -110,12 +110,6 @@
.edge[data-history-space] {
min-height: calc(var(--size-control) + var(--space-4));
}
-/* Start content stacks at the reading measure, aligned with the rows below. */
-.edge[data-start] {
- flex-direction: column;
- align-items: stretch;
- padding-block: var(--space-panel-inset) var(--space-2);
-}
.feed[data-chat-presentation] .edge:not([data-history-space]) {
min-height: var(--space-1);
}
From 1f746f0438a6bc879fea533787e2d13c69f68154 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 01:27:34 +1100
Subject: [PATCH 22/28] fix(communities): bind the welcome read to the viewer,
forget it on leave, keep the card off the typing pill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-ups for the pinned welcome.
The one read without kinds now names the viewer alone. The session reader
admits the author-only filter only when its author is the transport's
viewer, and the browser broker only for its own identity's pubkey, so no
caller can ask for another author's newest event this way. The relay side
(block/buzz 7f6bca943, PR #9108) exempts the same shape.
A kept answer (welcome-earlier-activity) is read during render through a
subscription on the session's partition, so a card it allows is in the
first commit with the channel, instead of one commit after it painted and
then growing the history's end. The network read stays in the effect and
runs only without an answer; it never retries within the same mount.
Leaving or removing a community forgets its welcome record with the rest of
its device state. welcome-first-open and welcome-dismissed:* are scoped by
origin alone, which the origin:viewer purge never reached, so the record
outlived the membership. clearViewScope takes an optional key prefix, and
the purge clears the origin's `welcome-` keys and nothing else there. A
rejoin records a fresh first open; the read still classes a member with
older events as existing. device-state and communities.md say so.
Dismissal stays keyed by origin and channel, by decision: a welcome moved
to another channel is a new welcome, and may show once more there to a
member this device classes as new. The moderation README, channels.md and
buzz-help.md now say that edits to the title or message never reshow it
while a move can, and a test covers dismiss in A, show in B, and A staying
dismissed on this and the next open.
The card no longer covers "X is typing". Reasoned from the CSS, not run: the
pill (.typing[data-audience="humans"]) is absolute in the composer context,
its bottom space-half above the composer's top, which is the history
section's bottom edge; it is a caption line with space-1 padding each side,
so it occupies the 2–26 px above the composer at the default text size,
scaling with --type-scale. The card was space-3 above that edge at z-index
10, over the pill's z-index 1, so it covered the pill from 12 px up. The
pinned slot's block-end padding is now space-3 plus that lane (space-half +
2 × space-1 + caption size × caption line-height), which tracks enlarged
text since the caption tokens carry --type-scale while space tokens do not.
The slot's measured height includes the padding, so the history's end
grows by it too and the last message still scrolls clear.
The card keeps floating-surface and sets --floating-inset beside its
padding, per DESIGN.md Floating surfaces. The join notice's glass-secondary
material was considered: it has no reduced-transparency or forced-colors
fallback and uses a capsule radius, so switching would trade accessibility
fallbacks for an unverified visual change.
channels.md also says replaceable events such as profiles count only if
last updated before T − 600, since a relay keeps only their newest version.
Tests were written but not run on this machine; tsc --noEmit, Biome and
design:check pass.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
browser-host/relay-broker.mjs | 6 +--
browser-host/relay-broker.test.mjs | 25 ++++++---
docs/buzz-help.md | 3 +-
docs/channels.md | 15 ++++--
docs/communities.md | 3 ++
src/bundled/moderation/README.md | 4 +-
.../communities/WelcomeCard.module.css | 16 +++++-
src/features/communities/device-state.test.ts | 11 ++++
src/features/communities/device-state.ts | 6 ++-
src/features/communities/welcome-pin.test.tsx | 54 +++++++++++++++++--
src/features/communities/welcome-pin.ts | 52 +++++++++++++-----
src/features/relay/earlier-activity.ts | 11 ++--
src/features/relay/reader.test.ts | 9 +++-
src/features/relay/reader.ts | 2 +-
src/shared/view-state.ts | 8 +--
15 files changed, 180 insertions(+), 45 deletions(-)
diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs
index 83dcef9147..b7484a361e 100644
--- a/browser-host/relay-broker.mjs
+++ b/browser-host/relay-broker.mjs
@@ -751,7 +751,7 @@ export function validChannelActivityFilters(filters) {
)
);
}
-export function validFilters(filters) {
+export function validFilters(filters, viewer) {
return (
Array.isArray(filters) &&
filters.length >= 1 &&
@@ -770,7 +770,7 @@ export function validFilters(filters) {
(filter.kinds === undefined &&
Array.isArray(filter.ids) &&
filter.ids.length > 0) ||
- isEarlierActivityFilter(filter)) &&
+ isEarlierActivityFilter(filter, viewer)) &&
Number.isInteger(filter.limit) &&
filter.limit >= 1 &&
filter.limit <= MAX_LIMIT,
@@ -3150,7 +3150,7 @@ export function relayBrokerPlugin({
!readPublishing &&
!snapshot &&
!channelActivity &&
- !validFilters(filters)
+ !validFilters(filters, viewer)
)
return json(res, 400, { error: "Read filter rejected" });
if (publishing || readPublishing) {
diff --git a/browser-host/relay-broker.test.mjs b/browser-host/relay-broker.test.mjs
index fe592a9d20..2466dcaf12 100644
--- a/browser-host/relay-broker.test.mjs
+++ b/browser-host/relay-broker.test.mjs
@@ -14,10 +14,14 @@ test("read broker accepts non-channel finite reads without relaxing filter budge
{ ids: ["event"], limit: 1 },
{ kinds: [9], search: "design", limit: 20 },
{ kinds: [9], "#e": ["root"], depth_limit: 10, limit: 80 },
- // The welcome card's one question: has the viewer written anything yet?
- { authors: ["a".repeat(64)], until: 1788810000, limit: 1 },
])
assert.equal(validFilters([filter]), true);
+ // The welcome card's one question: has the viewer written anything yet?
+ const viewer = "a".repeat(64);
+ assert.equal(
+ validFilters([{ authors: [viewer], until: 1788810000, limit: 1 }], viewer),
+ true,
+ );
for (const filters of [
[],
Array(5).fill({ kinds: [0], limit: 1 }),
@@ -27,14 +31,19 @@ test("read broker accepts non-channel finite reads without relaxing filter budge
[{ kinds: [65536], limit: 1 }],
[{ kinds: [], limit: 1 }],
[{ limit: 1 }],
- // Only that exact author-only shape may omit kinds.
- [{ authors: ["a".repeat(64)], limit: 1 }],
- [{ authors: ["a".repeat(64)], until: 1788810000, limit: 2 }],
- [{ authors: ["a".repeat(64), "b".repeat(64)], until: 1, limit: 1 }],
- [{ authors: ["a".repeat(64)], until: 1, limit: 1, "#h": ["channel"] }],
+ // Only that exact shape, about the broker's own identity, may omit kinds.
+ [{ authors: [viewer], limit: 1 }],
+ [{ authors: [viewer], until: 1788810000, limit: 2 }],
+ [{ authors: [viewer, "b".repeat(64)], until: 1, limit: 1 }],
+ [{ authors: [viewer], until: 1, limit: 1, "#h": ["channel"] }],
[{ authors: ["viewer"], until: 1, limit: 1 }],
+ [{ authors: ["b".repeat(64)], until: 1788810000, limit: 1 }],
])
- assert.equal(validFilters(filters), false);
+ assert.equal(validFilters(filters, viewer), false);
+ assert.equal(
+ validFilters([{ authors: [viewer], until: 1788810000, limit: 1 }]),
+ false,
+ );
});
test("broker signing is limited to bounded channel messages and canonical direct and nested replies", () => {
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index e9389487d4..fd2459d76e 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -466,7 +466,8 @@ revokes all of them, and a pairing unused for 30 days expires.
public channel that new members join and open first. There, a new member
sees a welcome card above the message box, showing the community's message
or a default one. Choose **Dismiss** to hide it on this computer; it stays
- hidden even if the message changes. People who had already written in the
+ hidden even if the title or message changes, though a welcome moved to
+ another channel can show once more there. People who had already written in the
community never see it, though someone who has never posted may see it once
on each new device. The card isn't a message: it can't be replied to or
deleted. While
diff --git a/docs/channels.md b/docs/channels.md
index 594f504ccd..c0319f0f1c 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1384,15 +1384,19 @@ It shows only while all four of these hold:
read, through the session's snapshot read (the HTTP `/query` bridge on both
hosts), asks `{ "authors": [viewer], "until": T - 600, "limit": 1 }`, with no
kinds and no `#h`, so any kind in any channel the relay lets the viewer read
- counts, profiles and preferences included. An event means an existing member.
+ counts. Replaceable events such as profiles and preferences count only if
+ last updated before then, since a relay keeps only their newest version. An
+ event means an existing member.
Because of the ten minutes, a newcomer's own events from joining, such as a
profile set up just before the app opened, do not count. The answer is kept
per device under `welcome-earlier-activity`, scoped by origin and viewer, so
another identity on the same device asks for itself. The session reader and
the browser broker admit no other filter without kinds or IDs.
4. The card has not been dismissed on this device: `welcome-dismissed:`, scoped by origin alone. A new title or message from an admin does not
- bring it back.
+ id>`, scoped by origin alone. A new title or message from an admin never
+ brings it back. A welcome moved to another channel is a new welcome: it can
+ show once more there, to a member this device still classes as new, and the
+ channel it left stays dismissed.
Nothing shows while the read is pending. A read that fails, whether refused,
timed out, offline or without a session, is no answer: nothing is kept and the
@@ -1403,7 +1407,10 @@ These are accepted trade-offs. Someone with no events at all, such as a lurker,
looks new on each new device and sees the card once there. Dismissal is per
device, and shared by the identities on it, as T is. A lurker already in a
channel that becomes the welcome channel, such as `#general`, sees the card
-once per device after the upgrade.
+once per device after the upgrade. Leaving or removing the community forgets T
+and the dismissals with the rest of its device state (see
+[Communities](communities.md)), so a rejoin starts over, with the read still
+classing a member with older events as existing.
A cached roster can display previously downloaded, reverified history for up to
24 hours. It cannot authorize head/history reads, unread evidence, typing or
diff --git a/docs/communities.md b/docs/communities.md
index e47d705657..b91cc35256 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -144,6 +144,9 @@ sessions. Leave asks for confirmation, then sends a signed NIP-43 leave request
answers end the membership here. The relay accepting, or answering that the
viewer is not a member, removes the community and clears its drafts, reading
positions and other device state, since the relay holds nothing to go back to.
+That includes the welcome record the device keeps per community for all of its
+identities (when it first opened it and which welcomes were dismissed), so a
+rejoin by any identity here starts it afresh.
The relay answering that the viewer is banned also removes the community and
disposes its session, but keeps that device state: the relay still holds the
membership while a ban lasts (bans can be timed or lifted), and the data is
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 81fee55941..8b7d8552fc 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -26,7 +26,9 @@ toolbar and saves Markdown; pasted channel and
message links show with the same link presentation as in messages, through
the conversation's link renderers. Saving moves the welcome to the chosen
channel; people already in the community keep their channels. Editing the
-welcome does not show the card again to anyone who dismissed it.
+title or message never shows the card again to anyone who dismissed it;
+moving the welcome to another channel is a new welcome, which can show once
+more there.
Below that, **Also add new members to** lists the starter channels, which new
members join too: **Add a channel** searches other open channels, and **Remove**
diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css
index 81edcc85cd..b6cb902438 100644
--- a/src/features/communities/WelcomeCard.module.css
+++ b/src/features/communities/WelcomeCard.module.css
@@ -8,13 +8,27 @@
display: flex;
justify-content: center;
padding: var(--space-3);
+ /* Unlike a preview, this channel has a composer, and "X is typing" floats a
+ caption pill (padding space-1, raised space-half) just above it, in this
+ same corner (TypingIndicator.module.css). Leave that lane below the card,
+ and the reported height carries it to the history's end. */
+ padding-block-end: calc(
+ var(--space-3) +
+ var(--space-half) +
+ 2 *
+ var(--space-1) +
+ var(--text-caption) *
+ var(--text-caption--line-height)
+ );
pointer-events: none;
}
.card {
@apply floating-surface;
+ /* Content padding: keep nested corners concentric (DESIGN.md, Floating surfaces). */
+ --floating-inset: var(--space-3);
pointer-events: auto;
width: min(100%, 40rem);
- padding: var(--space-3) var(--space-4) var(--space-4);
+ padding: var(--floating-inset) var(--space-4) var(--space-4);
color: var(--text-standard);
text-align: start;
}
diff --git a/src/features/communities/device-state.test.ts b/src/features/communities/device-state.test.ts
index c3e3231c51..493ba08b88 100644
--- a/src/features/communities/device-state.test.ts
+++ b/src/features/communities/device-state.test.ts
@@ -86,7 +86,18 @@ it("forgets every store partitioned to the left community and viewer, and nothin
localStorage.setItem(receipt(`${kept}:${viewer}`), "1");
localStorage.setItem(follows(scope), "[]");
localStorage.setItem(follows(`${kept}:${viewer}`), "[]");
+ // The welcome record is the one store scoped by origin alone.
+ writeView(origin, "welcome-first-open", 1_788_810_000);
+ writeView(origin, "welcome-dismissed:general", true);
+ writeView(origin, "unrelated", "kept");
+ writeView(kept, "welcome-first-open", 1_788_810_000);
expect(await purgeCommunityDeviceState(origin, viewer)).toEqual([]);
+ expect(readView(origin, "welcome-first-open", undefined)).toBeUndefined();
+ expect(
+ readView(origin, "welcome-dismissed:general", undefined),
+ ).toBeUndefined();
+ expect(readView(origin, "unrelated", undefined)).toBe("kept");
+ expect(readView(kept, "welcome-first-open", undefined)).toBe(1_788_810_000);
expect(
localStorage.getItem(localCommunityNameKey(origin, viewer)),
).toBeNull();
diff --git a/src/features/communities/device-state.ts b/src/features/communities/device-state.ts
index 2a1d2207a4..9e2e9e8470 100644
--- a/src/features/communities/device-state.ts
+++ b/src/features/communities/device-state.ts
@@ -8,6 +8,7 @@ import { forgetThreadFollows } from "../relay/thread-follows";
import { clearViewScope } from "../../shared/view-state";
import { saveLocalCommunityName } from "./names";
import { saveRepositoriesFolder } from "./repositories";
+import { forgetWelcome } from "./welcome-pin";
/** One piece of a left community's device state that could not be cleared. */
export type PurgeFailure = { store: string; error: unknown };
@@ -27,7 +28,9 @@ function purgeFailure(
/** Forgets what this device keeps for one community and viewer: view intent
* (selected channel, drafts, scroll and sidebar state), channel heads, the
* read-state journal, the outbox and smaller preference stores. Every store is
- * partitioned by `origin:viewer`, so nothing here can touch another community.
+ * partitioned by `origin:viewer`, so nothing here can touch another community,
+ * except the welcome record (first open and dismissals), which the device
+ * keeps per origin for all of its identities and forgets here with the rest.
* Each store is cleared independently: an unavailable one does not keep the
* others, and the caller has already dropped the membership. Every failure is
* logged and returned, named by store, so the caller can say some saved data
@@ -55,6 +58,7 @@ export async function purgeCommunityDeviceState(
saveRepositoriesFolder(origin, viewer, "");
});
await attempt("view state", () => clearViewScope(scope));
+ await attempt("welcome", () => forgetWelcome(origin));
await attempt("channel setups", () => forgetChannelSetups(scope));
await attempt("quick reactions", () => forgetQuickReactions(scope));
await attempt("thread follows", () => forgetThreadFollows(scope));
diff --git a/src/features/communities/welcome-pin.test.tsx b/src/features/communities/welcome-pin.test.tsx
index aad71ddd1c..f023b762e9 100644
--- a/src/features/communities/welcome-pin.test.tsx
+++ b/src/features/communities/welcome-pin.test.tsx
@@ -21,6 +21,7 @@ const me = "a".repeat(64);
const other = "b".repeat(64);
const scope = `${origin}:${me}`;
const channel = "11111111-1111-4111-8111-111111111111";
+const moved = "22222222-2222-4222-8222-222222222222";
/** When this device first opened the community. */
const T = 1_788_810_000;
@@ -85,20 +86,26 @@ function fakeRelay(events: RelayEvent[] = []) {
function Slot({
relay,
viewer = me,
+ channelId = channel,
title,
message = "Say hi",
+ onRender,
}: {
relay: ReturnType | undefined;
viewer?: string;
+ channelId?: string;
title?: string;
message?: string;
+ /** Sees what each render decided, in order. */
+ onRender?: (shown: boolean) => void;
}) {
const welcome = usePinnedWelcome({
session: { viewer, snapshotRead: relay?.snapshotRead },
scope: `${origin}:${viewer}`,
- channelId: channel,
+ channelId,
member: true,
});
+ onRender?.(welcome.shown);
return welcome.shown ? (
{
+it("shows a kept answer in the first commit, without asking again", () => {
writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ writeView(scope, WELCOME_EARLIER_ACTIVITY_KEY, false);
const relay = fakeRelay();
- const { unmount } = render( );
+ const renders: boolean[] = [];
+ render( renders.push(shown)} />);
+ expect(renders[0]).toBe(true);
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(relay.reads).toHaveLength(0);
+});
+
+it("keeps nothing from a failed read and asks again on the next open, not in the same one", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ const { rerender, unmount } = render( );
await waitFor(() => expect(relay.reads).toHaveLength(1));
await relay.reads[0]?.fail();
expect(screen.queryByRole("region")).toBeNull();
expect(viewRevision(scope, WELCOME_EARLIER_ACTIVITY_KEY)).toBeNull();
+ // Rendering again is not a new open: the same mount never retries.
+ rerender( );
+ await act(async () => {});
+ expect(relay.reads).toHaveLength(1);
unmount();
render( );
await waitFor(() => expect(relay.reads).toHaveLength(2));
@@ -235,3 +257,29 @@ it("stays dismissed when an admin changes the welcome afterwards", async () => {
expect(screen.queryByRole("region")).toBeNull();
expect(screen.queryByText("Edited")).toBeNull();
});
+
+it("shows the welcome once more in a channel it moves to, and never again where it was dismissed", async () => {
+ writeView(origin, WELCOME_FIRST_OPEN_KEY, T);
+ const relay = fakeRelay();
+ const { rerender, unmount } = render( );
+ await waitFor(() => expect(relay.reads).toHaveLength(1));
+ await relay.reads[0]?.answer();
+ await userEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByRole("region")).toBeNull();
+ // An admin moves the welcome to another channel: a new welcome, served by
+ // the kept answer without another read.
+ rerender( );
+ expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible();
+ expect(relay.reads).toHaveLength(1);
+ expect(
+ readView(origin, welcomeDismissedKey(moved), undefined),
+ ).toBeUndefined();
+ // Moved back, it stays dismissed there, on this and the next open.
+ rerender( );
+ expect(screen.queryByRole("region")).toBeNull();
+ unmount();
+ render( );
+ await act(async () => {});
+ expect(screen.queryByRole("region")).toBeNull();
+ expect(relay.reads).toHaveLength(1);
+});
diff --git a/src/features/communities/welcome-pin.ts b/src/features/communities/welcome-pin.ts
index 3a8a833ded..21ca153029 100644
--- a/src/features/communities/welcome-pin.ts
+++ b/src/features/communities/welcome-pin.ts
@@ -3,6 +3,7 @@ import type { SnapshotRead } from "../agents/agent-config-artifacts";
import { earlierActivityFilter } from "../relay/earlier-activity";
import { splitPartition } from "../relay/partition";
import {
+ clearViewScope,
readView,
replaceView,
subscribeView,
@@ -18,9 +19,12 @@ export const WELCOME_FIRST_OPEN_KEY = "welcome-first-open";
/** View key, scoped by relay and viewer: whether the relay had an event by the
* viewer from before {@link WELCOME_FIRST_OPEN_KEY} less the grace period. */
export const WELCOME_EARLIER_ACTIVITY_KEY = "welcome-earlier-activity";
-/** View key, scoped by relay alone: this welcome channel's card was dismissed. */
+/** View key, scoped by relay alone: this welcome channel's card was dismissed.
+ * A welcome moved to another channel is a new welcome, with its own key. */
export const welcomeDismissedKey = (channelId: string) =>
`welcome-dismissed:${channelId}`;
+/** Every welcome key scoped by relay alone starts with this. */
+const WELCOME_KEY_PREFIX = "welcome-";
const origin = (scope: string) => splitPartition(scope)?.communityOrigin;
const unscoped = () => () => {};
@@ -34,6 +38,14 @@ export function recordWelcomeFirstOpen(scope: string, now = Date.now()) {
replaceView(relay, WELCOME_FIRST_OPEN_KEY, null, Math.floor(now / 1000));
}
+/** Forgets the welcome state this device keeps for the community at `origin`
+ * across its identities: the first open and every dismissal. A later rejoin
+ * records a fresh first open; the relay's answer still classes a member with
+ * older events as existing. */
+export function forgetWelcome(origin: string) {
+ clearViewScope(origin, WELCOME_KEY_PREFIX);
+}
+
/**
* Whether the welcome channel `channelId` shows its pinned welcome to this
* viewer, who must be a member of it (`member`): only while this device has a
@@ -76,20 +88,32 @@ export function usePinnedWelcome({
);
// Hides at once even if storage cannot keep the dismissal.
const [closed, setClosed] = useState();
- const [answer, setAnswer] = useState<{ scope: string; earlier: boolean }>();
const asking =
member && !!channelId && !dismissed && closed !== channelId && !!relay;
+ // A kept answer is read during render, so a card it allows is in the first
+ // commit with the channel, not one commit after it painted.
+ const kept = useSyncExternalStore(
+ useCallback(
+ (listener: () => void) => subscribeView(scope, listener),
+ [scope],
+ ),
+ () => {
+ if (!asking) return undefined;
+ const value = readView(
+ scope,
+ WELCOME_EARLIER_ACTIVITY_KEY,
+ undefined,
+ );
+ return typeof value === "boolean" ? value : undefined;
+ },
+ );
+ // The relay's answer when storage could not keep it.
+ const [answer, setAnswer] = useState<{ scope: string; earlier: boolean }>();
+ const earlier =
+ kept ?? (answer?.scope === scope ? answer.earlier : undefined);
+ const answered = earlier !== undefined;
useEffect(() => {
- if (!asking || !relay || !viewer) return;
- const cached = readView(
- scope,
- WELCOME_EARLIER_ACTIVITY_KEY,
- undefined,
- );
- if (typeof cached === "boolean") {
- setAnswer({ scope, earlier: cached });
- return;
- }
+ if (!asking || answered || !relay || !viewer) return;
const firstOpen = readView(relay, WELCOME_FIRST_OPEN_KEY, null);
if (
!read ||
@@ -112,14 +136,14 @@ export function usePinnedWelcome({
},
);
return () => controller.abort();
- }, [asking, relay, scope, viewer, read]);
+ }, [asking, answered, relay, scope, viewer, read]);
const dismiss = useCallback(() => {
if (!relay || !dismissKey || !channelId) return;
setClosed(channelId);
writeView(relay, dismissKey, true);
}, [relay, dismissKey, channelId]);
return {
- shown: asking && answer?.scope === scope && !answer.earlier,
+ shown: asking && earlier === false,
dismiss,
};
}
diff --git a/src/features/relay/earlier-activity.ts b/src/features/relay/earlier-activity.ts
index 5c8dd3b48e..19cf974592 100644
--- a/src/features/relay/earlier-activity.ts
+++ b/src/features/relay/earlier-activity.ts
@@ -1,13 +1,17 @@
/** Whether the viewer wrote anything this relay can show them, of any kind and
* in any channel they can read, at or before `until` (unix seconds): one event
* answers the question. The only read without kinds or event IDs that the
- * session reader and the browser broker admit. */
+ * session reader and the browser broker admit, and only about the viewer. */
export function earlierActivityFilter(viewer: string, until: number) {
return { authors: [viewer], until, limit: 1 };
}
-/** Exactly the shape `earlierActivityFilter` builds; nothing more. */
-export function isEarlierActivityFilter(filter: unknown): boolean {
+/** Exactly the shape `earlierActivityFilter` builds for `viewer`; nothing
+ * more, and never about another author. */
+export function isEarlierActivityFilter(
+ filter: unknown,
+ viewer: string | undefined,
+): boolean {
if (!filter || typeof filter !== "object") return false;
const { authors, until, limit, ...rest } = filter as Record;
return (
@@ -16,6 +20,7 @@ export function isEarlierActivityFilter(filter: unknown): boolean {
authors.length === 1 &&
typeof authors[0] === "string" &&
/^[0-9a-f]{64}$/.test(authors[0]) &&
+ authors[0] === viewer &&
Number.isSafeInteger(until) &&
(until as number) >= 0 &&
limit === 1
diff --git a/src/features/relay/reader.test.ts b/src/features/relay/reader.test.ts
index f0c0f579e0..061e14ddbd 100644
--- a/src/features/relay/reader.test.ts
+++ b/src/features/relay/reader.test.ts
@@ -153,9 +153,13 @@ it("supports the desktop's ID, reference, address, and application-data reads wi
}
});
-it("admits the welcome card's author-only read without kinds, and no other", async () => {
- const { read, next } = setup();
+it("admits the welcome card's author-only read without kinds, about the viewer alone", async () => {
const viewer = "a".repeat(64);
+ const scripted = scriptedTransport(viewer, "relay");
+ const owner = createRelayReader(scripted.transport);
+ owners.push(owner);
+ const { next } = scripted;
+ const { read } = owner.reader;
const probe = { authors: [viewer], until: 1_788_810_000, limit: 1 };
const result = read([probe]);
const wire = next();
@@ -167,6 +171,7 @@ it("admits the welcome card's author-only read without kinds, and no other", asy
{ authors: [viewer], until: 1, limit: 2 },
{ authors: [viewer], until: 1, limit: 1, "#h": ["channel"] },
{ authors: [viewer, "b".repeat(64)], until: 1, limit: 1 },
+ { authors: ["b".repeat(64)], until: 1_788_810_000, limit: 1 },
])
await expect(read([refused])).rejects.toThrow(
"Relay reads need valid kinds or event IDs",
diff --git a/src/features/relay/reader.ts b/src/features/relay/reader.ts
index d22be9d764..eae561dcaa 100644
--- a/src/features/relay/reader.ts
+++ b/src/features/relay/reader.ts
@@ -279,7 +279,7 @@ export function createRelayReader(
)
) &&
!(filter.kinds === undefined && filter.ids?.length) &&
- !isEarlierActivityFilter(filter)
+ !isEarlierActivityFilter(filter, transport.viewer)
)
throw new Error("Relay reads need valid kinds or event IDs");
if (
diff --git a/src/shared/view-state.ts b/src/shared/view-state.ts
index e5a8a8e3f0..be546303a7 100644
--- a/src/shared/view-state.ts
+++ b/src/shared/view-state.ts
@@ -176,9 +176,11 @@ export function listDraftViews(
return { entries: drafts, limited, unavailable };
}
-/** Forgets every key saved under one scope, for a community the viewer has left. */
-export function clearViewScope(scope: string) {
- const prefix = `buzz-view.v1:${JSON.stringify([scope]).slice(0, -1)},`;
+/** Forgets every key saved under one scope, for a community the viewer has
+ * left, or only the keys there that start with `keyPrefix`. */
+export function clearViewScope(scope: string, keyPrefix = "") {
+ // JSON escapes per character, so an encoded key starts with its encoded prefix.
+ const prefix = `buzz-view.v1:${JSON.stringify([scope, keyPrefix]).slice(0, -2)}`;
const stale: string[] = [];
for (let index = 0; index < localStorage.length; index++) {
const key = localStorage.key(index);
From 91d89aceb3387e5c0e55faafdd06b403b01f9b7c Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 01:50:49 +1100
Subject: [PATCH 23/28] fix(communities): reference the global stylesheet from
the welcome card module
The welcome card's CSS module applies the floating-surface utility but did not reference the global stylesheet, so Tailwind refused the unknown class and Vite failed the import, taking Messages and the Welcome pane down with it. Every other module that applies a utility carries the reference; this one now does too.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
src/features/communities/WelcomeCard.module.css | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css
index b6cb902438..c66619e2c1 100644
--- a/src/features/communities/WelcomeCard.module.css
+++ b/src/features/communities/WelcomeCard.module.css
@@ -1,3 +1,5 @@
+@reference "../../shared/styles/globals.css";
+
/* Floats over the end of the history, as a channel's join notice does; the
page extends the history's end so its last messages scroll clear. */
.pinned {
From 6389f8329e379f0507eaccdd5287737a3c64a3ac Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 02:16:03 +1100
Subject: [PATCH 24/28] fix(communities): format the welcome command check; let
the policy relay answer the join policy
CI run 42237615305 failed every JavaScript shard in `pnpm check`, before
vitest ran: Biome's formatter wanted the welcome-command error in
channel-lifecycle-protocol.ts wrapped. Code, formatting only.
Every failing browser journey and measurement (470 of 472 assertion
reports) failed on the harness's `report.unexpected` with one entry:
policy-relay's upstream fetch asserting that a bodyless request is the
NIP-11 read at `/`, and receiving `/api/join-policy`. Messages now reads
the welcome channel through the broker's info route, which fetches NIP-11
and then the join policy, as the join flow always has. The thrown
assertion became the broker's "Request failed ... /relay/info" 500, which
the client already took as "no welcome channel", so the hold was skipped
quietly; only the fixture was wrong. The policy relay now answers the
join policy as an open community does, with a 404, which the info route
reads as no policy. Test fixture, not product.
The fixture did serve NIP-11, so a relay without it was not the cause.
The broker and client are unchanged for that case: a relay without a
readable NIP-11 document cannot open a session on either host (both read
its `self`), the info route already passes a missing document's status
through at warn level, and Messages treats any failed read as
unsupported without logging.
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
src/features/relay/channel-lifecycle-protocol.ts | 4 +++-
tests/browser/policy-relay.mjs | 8 ++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts
index a0975b6d42..c0a4190ab1 100644
--- a/src/features/relay/channel-lifecycle-protocol.ts
+++ b/src/features/relay/channel-lifecycle-protocol.ts
@@ -121,7 +121,9 @@ export function validateLifecycleTemplate(event: EventTemplate): void {
lifecycleChannelId(event.tags[0]?.[1] ?? "");
if (event.kind === 9002 && event.tags.length > 2) {
if (!landingCommand(event.tags))
- throw new Error("Only the welcome channel and its welcome may be set here");
+ throw new Error(
+ "Only the welcome channel and its welcome may be set here",
+ );
return;
}
if (
diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs
index a1f66b3a94..2203c6b1ec 100644
--- a/tests/browser/policy-relay.mjs
+++ b/tests/browser/policy-relay.mjs
@@ -200,9 +200,13 @@ export function policyRelay({
try {
if (latencyMs)
await new Promise((resolve) => setTimeout(resolve, latencyMs));
- // NIP-11 is a public GET with no signed query body. The rail now reads
- // it for saved communities, including inactive ones.
+ // NIP-11 and the join policy are public GETs with no signed query body.
+ // The rail reads NIP-11 for saved communities, including inactive ones;
+ // Messages reads both (the broker's info route) for the welcome channel.
+ // These communities are open: they publish no join policy.
if (!init?.body) {
+ if (new URL(url).pathname === "/api/join-policy")
+ return new Response(null, { status: 404 });
expect(new URL(url).pathname).toBe("/");
return Response.json(discovery?.(communityOf(url)) ?? {});
}
From 93bc40c04aef696fd1b11870944caf3c3ee8f6b4 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 02:16:04 +1100
Subject: [PATCH 25/28] docs(communities): the welcome read is bound to the
viewer; leaving clears the record for every identity
Leftovers from the last review of the pinned welcome. channels.md now says
the session reader and the browser broker admit the one filter without
kinds or IDs only about the viewer. channels.md and communities.md record,
beside the accepted trade-offs, that leaving a community clears the
device's welcome record for all identities on it, so another identity that
had dismissed the card may see it once more.
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
docs/channels.md | 8 ++++++--
docs/communities.md | 4 +++-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/docs/channels.md b/docs/channels.md
index c0319f0f1c..8a6d3c1161 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1391,7 +1391,8 @@ It shows only while all four of these hold:
profile set up just before the app opened, do not count. The answer is kept
per device under `welcome-earlier-activity`, scoped by origin and viewer, so
another identity on the same device asks for itself. The session reader and
- the browser broker admit no other filter without kinds or IDs.
+ the browser broker admit this one filter without kinds or IDs, and only
+ about the viewer.
4. The card has not been dismissed on this device: `welcome-dismissed:`, scoped by origin alone. A new title or message from an admin never
brings it back. A welcome moved to another channel is a new welcome: it can
@@ -1410,7 +1411,10 @@ channel that becomes the welcome channel, such as `#general`, sees the card
once per device after the upgrade. Leaving or removing the community forgets T
and the dismissals with the rest of its device state (see
[Communities](communities.md)), so a rejoin starts over, with the read still
-classing a member with older events as existing.
+classing a member with older events as existing. Since T and the dismissals
+are shared by the identities on the device, one identity leaving clears them
+for all of them, and another identity that had dismissed the card may see it
+once more.
A cached roster can display previously downloaded, reverified history for up to
24 hours. It cannot authorize head/history reads, unread evidence, typing or
diff --git a/docs/communities.md b/docs/communities.md
index b91cc35256..682550039c 100644
--- a/docs/communities.md
+++ b/docs/communities.md
@@ -146,7 +146,9 @@ viewer is not a member, removes the community and clears its drafts, reading
positions and other device state, since the relay holds nothing to go back to.
That includes the welcome record the device keeps per community for all of its
identities (when it first opened it and which welcomes were dismissed), so a
-rejoin by any identity here starts it afresh.
+rejoin by any identity here starts it afresh. That is an accepted trade-off:
+another identity on this device that had dismissed the welcome may see it once
+more.
The relay answering that the viewer is banned also removes the community and
disposes its session, but keeps that device state: the relay still holds the
membership while a ban lasts (bans can be timed or lifted), and the data is
From f9ac09885d6b6300b5ac07ae6b9a4c3a89b577cc Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Sat, 7 Nov 2026 02:34:59 +1100
Subject: [PATCH 26/28] test(communities): fix the welcome tests on their first
vitest run
CI run 42241478772 was the first to run vitest on this branch (Biome had
stopped every earlier run). Four of six shards failed, in eight files.
WelcomeSettings.test.tsx (all 43) and WelcomeCard.test.tsx (2): test.
Their CommunityReader fakes built a new snapshot object on every call, so
useDefaultWelcomeTitle's useSyncExternalStore never settled ("Maximum
update depth exceeded"); the real reader returns its kept state. The link
registry fakes in one test had the same defect. They now return stable
snapshots. Run locally, that left 15 failures in WelcomeSettings, all
test defects:
- fireEvent.change sets the message editor's value while building the
event, outside act, so the editor's transaction updated the form
unflushed and Save, the errors and the count were asserted before it
applied. A setMessage helper sets the value inside act.
- Typing a title back in to the value the field already showed fired no
onChange; the test now clears the field first, as an admin would.
- /when you save/ also matched the section's static subtitle; it now
matches the starter announcement.
- An open channel search hides the rest of the pane from role queries,
so two lookups use `hidden: true`. The "keeps the starter list" case
clicked Check again with text in the search, which a real press
outside the combobox clears; it now holds the support re-read and
types while it is pending, which is when the list must survive.
- "shows Save only for a change" failed only because the previous test
left an unconsumed mockResolvedValueOnce; it passes once that test
does.
Both files pass locally (49 tests).
ChannelsPage.tabs.test.tsx (1): test. The workspace harness never served
a history window's 39006 bounds, so the welcome channel showed "invalid
window bounds" instead of the empty notice the test expects beside the
card. A test can now supply bounds in its events, and this one supplies
Beta's empty head. Failed in CI, passes locally.
readmes.test.ts (1): README. Bundled plugin guides have one "## Getting
started" section; the welcome paragraphs stay, under it, without their
own heading.
CommunityAdmin.native.test.tsx (1): test. The moderation plugin now
registers the Welcome card beside Membership in native builds too; the
test asserts both.
device-state.test.ts and service.test.ts (1 each): test, plus a log
wording change in code. When key enumeration fails, the welcome sweep
fails too and is reported as its own store, which the tests did not
expect. The store is now named "welcome record", so the warning reads
"Couldn't clear welcome record for on this device".
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
.../channels/ChannelsPage.tabs.test.tsx | 15 ++-
.../moderation/CommunityAdmin.native.test.tsx | 13 +-
src/bundled/moderation/README.md | 2 -
.../moderation/WelcomeSettings.test.tsx | 116 +++++++++++-------
src/features/communities/WelcomeCard.test.tsx | 22 ++--
src/features/communities/device-state.test.ts | 5 +-
src/features/communities/device-state.ts | 2 +-
src/features/communities/service.test.ts | 7 +-
8 files changed, 120 insertions(+), 62 deletions(-)
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 35be385cc5..0a9adcbd56 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -33,6 +33,7 @@ import type { RelayData } from "../../features/relay/service";
import { matchesEvent } from "../../features/relay/projection";
import type { ReadFilter, RelayEvent } from "../../features/relay/events";
import {
+ bounds,
keypair,
metadata,
roster,
@@ -306,7 +307,8 @@ it.each(["side", "main"] as const)(
/** A page over `events` with no saved selection and no registered panels.
* A read waits for the promise `hold` returns for its filters, if any; the
- * relay's welcome-channel support is read from `landing`, if given. */
+ * relay's welcome-channel support is read from `landing`, if given. A history
+ * window is served only for a channel whose 39006 bounds are in `events`. */
function workspace(
events: RelayEvent[],
viewer = keypair(),
@@ -322,7 +324,14 @@ function workspace(
query: async (filters) => {
await hold?.(filters);
return events.filter((event) =>
- filters.some((filter) => matchesEvent(event, filter)),
+ filters.some(
+ (filter) =>
+ matchesEvent(event, filter) ||
+ // The relay answers a window read with its bounds.
+ (event.kind === 39006 &&
+ !!filter.top_level &&
+ matchesEvent(event, { "#h": filter["#h"] ?? [], limit: 1 })),
+ ),
);
},
writer: {
@@ -442,6 +451,8 @@ it("opens the community's welcome channel before its auto-join channel and pins
["welcome", "Say **hi**\nand introduce yourself."],
["welcome_title", "Hello, newcomer"],
]),
+ // Beta's history is empty.
+ bounds(authority, "beta", "head", { has_more: false, next_cursor: null }),
],
viewer,
authority,
diff --git a/src/bundled/moderation/CommunityAdmin.native.test.tsx b/src/bundled/moderation/CommunityAdmin.native.test.tsx
index b9297362e7..53115aad19 100644
--- a/src/bundled/moderation/CommunityAdmin.native.test.tsx
+++ b/src/bundled/moderation/CommunityAdmin.native.test.tsx
@@ -190,7 +190,7 @@ afterEach(() => {
vi.mocked(invoke).mockClear();
});
-it("keeps the Membership card registered in a native build", () => {
+it("keeps the Membership and Welcome cards registered in a native build", () => {
expect(nativeIdentityEnabled()).toBe(true);
const settingsCards = { register: vi.fn() };
const data = relay(owner).relay;
@@ -201,7 +201,8 @@ it("keeps the Membership card registered in a native build", () => {
settingsCards,
effect: vi.fn(),
} as unknown as Parameters[0]);
- expect(settingsCards.register).toHaveBeenCalledExactlyOnceWith(
+ expect(settingsCards.register).toHaveBeenCalledTimes(2);
+ expect(settingsCards.register).toHaveBeenCalledWith(
expect.objectContaining({
id: "membership",
title: "Membership",
@@ -209,6 +210,14 @@ it("keeps the Membership card registered in a native build", () => {
component: expect.any(Function),
}),
);
+ expect(settingsCards.register).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: "welcome",
+ title: "Welcome",
+ section: "administration",
+ component: expect.any(Function),
+ }),
+ );
membership.dispose();
});
diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md
index 8b7d8552fc..83022d720a 100644
--- a/src/bundled/moderation/README.md
+++ b/src/bundled/moderation/README.md
@@ -16,8 +16,6 @@ community where you are an owner or admin.
Direct addition by public identity is retained behind `DIRECT_MEMBER_ADD_ENABLED`
for a possible return, but is hidden in the shipped UI.
-## Welcome channel
-
On relays that support it, **Settings → Community admin → Welcome** lets an
owner or admin choose the public channel new members join and open first, and
the title and message of the welcome card pinned above that channel's
diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx
index aaf690575e..8c3225216b 100644
--- a/src/bundled/moderation/WelcomeSettings.test.tsx
+++ b/src/bundled/moderation/WelcomeSettings.test.tsx
@@ -198,10 +198,13 @@ function setup({
for (const listener of listeners) listener();
});
};
+ // A stable snapshot, as the real reader keeps: a new one each call would
+ // never let useSyncExternalStore settle.
+ const saved = {
+ memberships: [{ id: "https://primary.example", ...membership }],
+ };
const communities = {
- snapshot: () => ({
- memberships: [{ id: "https://primary.example", ...membership }],
- }),
+ snapshot: () => saved,
subscribe: () => () => {},
} as unknown as CommunityReader;
vi.mocked(communityRequest).mockResolvedValue(info);
@@ -229,6 +232,13 @@ function setup({
const channel = () => screen.getByRole("combobox", { name: "Welcome channel" });
const titleField = () => screen.getByRole("textbox", { name: "Title" });
const messageField = () => screen.getByRole("textbox", { name: "Message" });
+/** Replace the whole message, as the published text arriving would. The
+ * editor's value setter dispatches its change at once, so it runs in act:
+ * fireEvent.change would set it while building the event, outside act. */
+const setMessage = (text: string) =>
+ act(() => {
+ (messageField() as ComposerInputElement).value = text;
+ });
const starters = () =>
screen.getByRole("region", { name: "Also add new members to" });
const removeLabel = (name: string) => `Remove #${name} from starter channels`;
@@ -283,7 +293,7 @@ it("writes toolbar formatting and a pasted channel link into the message as Mark
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
const input = messageField();
- fireEvent.change(input, { target: { value: "" } });
+ setMessage("");
// A channel's Copy link writes the bare link.
const link = `buzz://channel/${square}`;
act(() => {
@@ -328,11 +338,14 @@ it("shows a pasted channel link through the conversation's link renderer, as the
component: ({ url }) => {url} ,
};
const subscribe = () => () => {};
+ // Registries keep their snapshot until a contribution changes.
+ const links = [renderer];
+ const none: never[] = [];
setup({
extensions: {
- links: { snapshot: () => [renderer], subscribe },
- inline: { snapshot: () => [], subscribe },
- tools: { snapshot: () => [], subscribe },
+ links: { snapshot: () => links, subscribe },
+ inline: { snapshot: () => none, subscribe },
+ tools: { snapshot: () => none, subscribe },
},
});
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
@@ -360,8 +373,8 @@ const plainClipboard = (text: string) => ({
/** Paste `text` as plain text into a message field holding only `before` and
* `after`, with the caret between them. */
function pasteIntoMessage(text: string, before = "", after = "") {
+ setMessage(before + after);
const input = messageField() as ComposerInputElement;
- fireEvent.change(input, { target: { value: before + after } });
act(() => {
input.focus();
input.setSelectionRange(before.length, before.length);
@@ -469,7 +482,7 @@ it("keeps an npub pasted into toolbar code as pasted", async () => {
const { setLanding, user } = setup({ names: { [alice]: "Alice" } });
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
const input = messageField();
- fireEvent.change(input, { target: { value: "" } });
+ setMessage("");
act(() => {
input.focus();
document.dispatchEvent(new Event("selectionchange"));
@@ -596,9 +609,7 @@ it("requires a channel when none is designated and sends the defaults as empty",
);
expect(channel()).toHaveValue("");
expect(readLanding).not.toHaveBeenCalled();
- fireEvent.change(messageField(), {
- target: { value: `${DEFAULT_WELCOME_MESSAGE}\n` },
- });
+ setMessage(`${DEFAULT_WELCOME_MESSAGE}\n`);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.type(channel(), "town");
await user.click(await screen.findByRole("option", { name: "Town square" }));
@@ -617,7 +628,7 @@ it("requires a channel when none is designated and sends the defaults as empty",
it("normalizes line endings and trims the title and message before sending", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: " Hi\r\nall " } });
+ setMessage(" Hi\r\nall ");
fireEvent.change(titleField(), { target: { value: " Hello, newcomer " } });
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
@@ -633,25 +644,23 @@ it("normalizes line endings and trims the title and message before sending", asy
it("blocks messages the relay would refuse and restores the defaults in the draft", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: " \n " } });
+ setMessage(" \n ");
expect(
screen.getByText("Enter a welcome message, or restore the defaults."),
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- fireEvent.change(messageField(), { target: { value: "a".repeat(4001) } });
+ setMessage("a".repeat(4001));
expect(
screen.getByText("Shorten the message to 4,000 characters."),
).toBeVisible();
- fireEvent.change(messageField(), { target: { value: "a\u0007b" } });
+ setMessage("a\u0007b");
expect(
screen.getByText(
"Remove control characters. Line breaks and tabs are fine.",
),
).toBeVisible();
// Unicode scalar values, not UTF-16 units, as the relay counts; tabs pass.
- fireEvent.change(messageField(), {
- target: { value: `${"😀".repeat(1999)}\t${"😀".repeat(2000)}` },
- });
+ setMessage(`${"😀".repeat(1999)}\t${"😀".repeat(2000)}`);
expect(screen.getByText("4,000 of 4,000 characters")).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.change(titleField(), { target: { value: "Hello" } });
@@ -689,18 +698,18 @@ it("blocks invisible-only text and hidden direction characters, as the relay doe
const { setLanding } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
// Zero-width and braille-blank characters alone leave nothing to read.
- fireEvent.change(messageField(), { target: { value: "\u200B\u2800" } });
+ setMessage("\u200B\u2800");
expect(
screen.getByText("Enter a welcome message, or restore the defaults."),
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- fireEvent.change(messageField(), { target: { value: "\u202Eabc" } });
+ setMessage("\u202Eabc");
expect(
screen.getByText("Remove the hidden text-direction characters."),
).toBeVisible();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
// A zero-width space inside readable text is fine.
- fireEvent.change(messageField(), { target: { value: "Hello\u200Bworld" } });
+ setMessage("Hello\u200Bworld");
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.change(titleField(), { target: { value: " \u200B " } });
expect(
@@ -720,7 +729,7 @@ it('titles the default "Welcome" for a community known only by its address', asy
membership: { name: "primary.example" },
});
await waitFor(() => expect(titleField()).toHaveValue("Welcome"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await user.click(screen.getByRole("button", { name: "Save" }));
// An untouched default title is sent as the default.
await waitFor(() =>
@@ -745,7 +754,7 @@ it("resends a published title untouched, even one matching this viewer's default
},
});
await waitFor(() => expect(titleField()).toHaveValue("Welcome to Primary"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setLanding).toHaveBeenLastCalledWith(
@@ -766,7 +775,8 @@ it("resends a published title untouched, even one matching this viewer's default
expect.any(Function),
),
);
- // Typed back in, the same words are a custom title.
+ // Cleared and typed back in, the same words are a custom title.
+ fireEvent.change(titleField(), { target: { value: "" } });
fireEvent.change(titleField(), { target: { value: "Welcome to Primary" } });
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
@@ -788,7 +798,7 @@ it("shows the relay's reason and keeps the draft when it refuses", async () => {
),
);
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Landing requires an open, unarchived channel without a ttl",
@@ -803,7 +813,7 @@ async function unconfirmedSave(fixture: ReturnType) {
new ChannelLifecycleUnconfirmed(new Error("timeout")),
);
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await fixture.user.click(screen.getByRole("button", { name: "Save" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Not confirmed yet");
@@ -928,7 +938,7 @@ it("announces starter edits, and rests focus in the pane when Cancel or Save tak
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Welcome saved")).toBeInTheDocument();
// Save speaks for itself; the edit's announcement is withdrawn.
- expect(screen.queryByText(/when you save/)).toBeNull();
+ expect(screen.queryByText(/will be removed when you save/)).toBeNull();
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
expect(channel()).toHaveFocus();
});
@@ -939,12 +949,19 @@ it("keeps the starter list, its typed search and focus when a re-read finds the
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
await user.click(remove("Town square"));
await unconfirmedSave(fixture);
- await user.type(addField(), "ran");
- vi.mocked(communityRequest).mockResolvedValueOnce({
- policy: null,
- landingChannels: false,
- });
+ // The relay's support is read again, and answers while the admin is
+ // searching for a channel to add.
+ let answer!: () => void;
+ vi.mocked(communityRequest).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = () => resolve({ policy: null, landingChannels: false });
+ }),
+ );
await user.click(screen.getByRole("button", { name: "Check again" }));
+ await waitFor(() => expect(communityRequest).toHaveBeenCalledTimes(2));
+ await user.type(addField(), "ran");
+ await act(async () => answer());
expect(
await screen.findByText(
"This community’s relay doesn’t support a welcome channel.",
@@ -954,19 +971,24 @@ it("keeps the starter list, its typed search and focus when a re-read finds the
expect(addField()).toHaveValue("ran");
expect(addField()).toHaveFocus();
expect(removable("Town square")).toBeNull();
- expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
+ // The search's open list hides the action row from role queries.
+ expect(
+ screen.getByRole("button", { name: "Save", hidden: true }),
+ ).toBeEnabled();
+ expect(
+ screen.queryByRole("textbox", { name: "Message", hidden: true }),
+ ).toBeNull();
});
it("shows Save only for a change, and Cancel returns to the published state", async () => {
const { setLanding, user } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Hi there!" } });
+ setMessage("Hi there!");
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
// Typing back to the published text leaves nothing to save.
- fireEvent.change(messageField(), { target: { value: "Hi there" } });
+ setMessage("Hi there");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- fireEvent.change(messageField(), { target: { value: "Draft" } });
+ setMessage("Draft");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(messageField()).toHaveValue("Hi there");
expect(channel()).toHaveValue("Lobby");
@@ -977,7 +999,7 @@ it("shows Save only for a change, and Cancel returns to the published state", as
it("starts over from the relay when the community's session changes", async () => {
const { readLanding, reconnect } = setup();
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Draft" } });
+ setMessage("Draft");
reconnect();
await waitFor(() => expect(readLanding).toHaveBeenCalledTimes(2));
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
@@ -994,7 +1016,7 @@ it("abandons a save that is still in flight when the form closes", async () => {
}),
);
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(setLanding).toHaveBeenCalledTimes(1));
const [, , signal, active] = setLanding.mock.calls[0] ?? [];
@@ -1148,7 +1170,7 @@ it("adds a starter to the draft and publishes it, after the welcome, on Save", a
expect(remove("Random")).toBeEnabled();
// A starter change alone offers Save.
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
const release = holdRun(run, flag);
await user.click(screen.getByRole("button", { name: "Save" }));
try {
@@ -1306,7 +1328,7 @@ it("publishes the steps before a refusal and keeps the ones after it in the draf
};
const { run, setLanding, user } = setup({ joined: [unlisted] });
await waitFor(() => expect(channel()).toHaveValue("Lobby"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
// Steps run by channel name: Random, Town square, then Unlisted.
await user.type(addField(), "ran");
await user.click(await screen.findByRole("option", { name: "Random" }));
@@ -1406,7 +1428,7 @@ it("holds starter edits while the welcome saves", async () => {
}),
);
await waitFor(() => expect(messageField()).toHaveValue("Hi there"));
- fireEvent.change(messageField(), { target: { value: "Say hello" } });
+ setMessage("Say hello");
await user.click(screen.getByRole("button", { name: "Save" }));
try {
expect(remove("Town square")).toBeDisabled();
@@ -1419,7 +1441,13 @@ it("holds starter edits while the welcome saves", async () => {
} finally {
await act(async () => settle());
}
- expect(remove("Town square")).toBeEnabled();
+ // The browse list is still open, hiding the rows from role queries.
+ expect(
+ screen.getByRole("button", {
+ name: removeLabel("Town square"),
+ hidden: true,
+ }),
+ ).toBeEnabled();
});
it("still lists and saves starter channels when the relay has no welcome channel", async () => {
diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx
index 78a1757505..a4e066c8bd 100644
--- a/src/features/communities/WelcomeCard.test.tsx
+++ b/src/features/communities/WelcomeCard.test.tsx
@@ -103,10 +103,12 @@ it("shows an identity link as an inert chip when not interactive, as the editor'
});
it("titles the default with the community the viewer saved, else a custom title", () => {
+ // A stable snapshot, as the real reader keeps it between changes.
+ const saved = {
+ memberships: [{ id: "https://primary.example", name: "Primary" }],
+ };
const communities = {
- snapshot: () => ({
- memberships: [{ id: "https://primary.example", name: "Primary" }],
- }),
+ snapshot: () => saved,
subscribe: () => () => {},
} as unknown as CommunityReader;
const { rerender } = render(
@@ -136,13 +138,15 @@ it("titles the default with the community the viewer saved, else a custom title"
});
it("welcomes a community known only by its address without naming it", () => {
- const reader = (membership: Record) =>
- ({
- snapshot: () => ({
- memberships: [{ id: "https://primary.example", ...membership }],
- }),
+ const reader = (membership: Record) => {
+ const saved = {
+ memberships: [{ id: "https://primary.example", ...membership }],
+ };
+ return {
+ snapshot: () => saved,
subscribe: () => () => {},
- }) as unknown as CommunityReader;
+ } as unknown as CommunityReader;
+ };
const { rerender } = render(
{});
- // Key enumeration fails, which the view and channel-setup sweeps rely on.
+ // Key enumeration fails, which the view, welcome and channel-setup sweeps
+ // rely on.
const denied = new Error("denied");
vi.spyOn(Storage.prototype, "key").mockImplementation(() => {
throw denied;
@@ -138,6 +139,7 @@ it("clears the remaining stores when one is unavailable and reports each failure
const failures = await purgeCommunityDeviceState(origin, viewer);
expect(failures).toEqual([
{ store: "view state", error: denied },
+ { store: "welcome record", error: denied },
{ store: "channel setups", error: denied },
]);
expect(localStorage.getItem(reactions(scope))).toBeNull();
@@ -145,6 +147,7 @@ it("clears the remaining stores when one is unavailable and reports each failure
// naming the store and the community it belongs to.
expect(warn.mock.calls).toEqual([
[`Couldn't clear view state for ${origin} on this device`, denied],
+ [`Couldn't clear welcome record for ${origin} on this device`, denied],
[`Couldn't clear channel setups for ${origin} on this device`, denied],
]);
});
diff --git a/src/features/communities/device-state.ts b/src/features/communities/device-state.ts
index 9e2e9e8470..8c0d5c4d14 100644
--- a/src/features/communities/device-state.ts
+++ b/src/features/communities/device-state.ts
@@ -58,7 +58,7 @@ export async function purgeCommunityDeviceState(
saveRepositoriesFolder(origin, viewer, "");
});
await attempt("view state", () => clearViewScope(scope));
- await attempt("welcome", () => forgetWelcome(origin));
+ await attempt("welcome record", () => forgetWelcome(origin));
await attempt("channel setups", () => forgetChannelSetups(scope));
await attempt("quick reactions", () => forgetQuickReactions(scope));
await attempt("thread follows", () => forgetThreadFollows(scope));
diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts
index 1a7348afba..cb5d09c5c7 100644
--- a/src/features/communities/service.test.ts
+++ b/src/features/communities/service.test.ts
@@ -625,7 +625,7 @@ it("leave still forgets the community when a store will not clear, and logs and
'[{"emoji":"🎉","count":1,"used":1}]',
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- // Key enumeration is what the view and channel-setup sweeps rely on.
+ // Key enumeration is what the view, welcome and channel-setup sweeps rely on.
const denied = new Error("denied");
vi.spyOn(localStorage, "key").mockImplementation(() => {
throw denied;
@@ -639,12 +639,17 @@ it("leave still forgets the community when a store will not clear, and logs and
).toBeNull();
expect(failures).toEqual([
{ store: "view state", error: denied },
+ { store: "welcome record", error: denied },
{ store: "channel setups", error: denied },
]);
expect(warn).toHaveBeenCalledWith(
`Couldn't clear view state for ${origin} on this device`,
denied,
);
+ expect(warn).toHaveBeenCalledWith(
+ `Couldn't clear welcome record for ${origin} on this device`,
+ denied,
+ );
expect(warn).toHaveBeenCalledWith(
`Couldn't clear channel setups for ${origin} on this device`,
denied,
From 6466ab8ed68af3d8c9a2f1d4d60ccc72d4ae99a8 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Mon, 9 Nov 2026 20:59:48 +1100
Subject: [PATCH 27/28] test(fixtures): provide adminAccess to the channels
plugin in the browser fixtures again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI run 42558686027 failed conversation.spec.mjs and both
channel-completion.spec.mjs cases for one reason: the channels plugin
never activated in the conversation and mentions fixtures, so no Messages
page and no "#" completion provider registered.
Commit 87bf2d891 removed the per-channel auto-join switch, dropped
`adminAccess` from the plugin's inject list and deleted the matching
`ctx.provide("adminAccess", …)` from both fixtures. origin/main (#2904,
create #general) has since made the plugin inject `adminAccess` again for
the first-channel setup, and the rebase kept main's plugin while keeping
the branch's fixture deletions. cordis waits for the missing service, so
the plugin's apply never ran and activation timed out.
Restore the one provide line in each fixture, as the vitest suites that
mount the plugin already do. Test fixtures only; no product change.
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Fable 5.1
Signed-off-by: Matt Toohey
---
tests/fixtures/conversation.tsx | 2 ++
tests/fixtures/mentions.tsx | 2 ++
2 files changed, 4 insertions(+)
diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx
index fcad4d3baf..79d7f04b81 100644
--- a/tests/fixtures/conversation.tsx
+++ b/tests/fixtures/conversation.tsx
@@ -320,6 +320,8 @@ ctx.provide("agents", {
snapshot: () => noAgents,
subscribe: () => () => {},
});
+// Channels hands community membership to its page for the first-channel setup.
+ctx.provide("adminAccess", { membership: undefined });
Object.assign(window, {
conversationFixture: {
report,
diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx
index eca6b5719c..e785a29c56 100644
--- a/tests/fixtures/mentions.tsx
+++ b/tests/fixtures/mentions.tsx
@@ -381,6 +381,8 @@ if (channels) {
sync: emptySync(),
}),
});
+ // Channels hands community membership to its page for the first-channel setup.
+ context.provide("adminAccess", { membership: undefined });
// Only the composer is mounted; the Channels page never consumes this service.
context.provide("relay", {
snapshot: () => ({ session: namedSession }),
From 665ba453b9badd4f4fd33104786fb055c6d41162 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Wed, 11 Nov 2026 11:49:38 +1100
Subject: [PATCH 28/28] test(communities): adapt the welcome tests to main's
navigation and line breaks
After rebasing onto main, two welcome tests failed:
- The moderation plugin also injects `navigation` since main's Membership
sharing change (PR 3147), so the Welcome card test provides it, as the
Membership test does.
- Rendered messages no longer write a newline after a ` ` since main's
authored-spaces change (PR 2815), so the welcome's soft break has no text
of its own. The test asserts the line break instead of a space.
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Opus 5.5
---
src/bundled/channels/ChannelsPage.tabs.test.tsx | 6 ++++--
src/bundled/moderation/registration.test.ts | 1 +
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx
index 0a9adcbd56..4d38f410b1 100644
--- a/src/bundled/channels/ChannelsPage.tabs.test.tsx
+++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx
@@ -464,9 +464,11 @@ it("opens the community's welcome channel before its auto-join channel and pins
const welcome = await screen.findByRole("region", {
name: "Hello, newcomer",
});
- // The message is Markdown, rendered as a message body is.
- expect(welcome).toHaveTextContent("Say hi and introduce yourself.");
+ // The message is Markdown, rendered as a message body is: its single
+ // newline is a line break.
+ expect(welcome).toHaveTextContent("Say hiand introduce yourself.");
expect(welcome.querySelector("strong")).toHaveTextContent("hi");
+ expect(welcome.querySelector("strong + br")).not.toBeNull();
// Pinned beside the history, not a row in it; an empty channel says so.
expect(welcome.closest("[data-message-scroller]")).toBeNull();
expect(await screen.findByText("No messages yet.")).toBeVisible();
diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts
index 136bc64eb3..d9d0c850fb 100644
--- a/src/bundled/moderation/registration.test.ts
+++ b/src/bundled/moderation/registration.test.ts
@@ -125,6 +125,7 @@ it.each([
landingChannels: false,
});
const root = new Context();
+ provideNavigation(root);
root.provide("pluginStatus", {
isActive: () => true,
subscribe: () => () => {},