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={ + + } + /> +
) : (
{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} +

+ + + ) : load.status === "unsupported" ? ( +

+ This community’s relay doesn’t support a welcome channel. +

+ ) : undefined + } + > +
+ +
{ + 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) + setError(saveError(reason)); + } finally { + if (mounted.current) setSaving(false); + } + })(); + }} + > + + 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 + > + + + + {(option: Option) => ( + + {option.name} + + )} + + + + {directory.error && ( +
+ +
+ )} + +