Skip to content

WIP feat(communities): open new members in a welcome channel and let admins set its welcome - #2623

Draft
matt2e wants to merge 28 commits into
mainfrom
primary-welcome-channel
Draft

matt2e wants to merge 28 commits into
mainfrom
primary-welcome-channel

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A community can now pick a welcome channel. Messages opens it first for new members, who see a welcome card with a title and a Markdown message pinned above its composer until they dismiss it. Owners and admins pick the channel, write the title and message, and choose the other starter channels new members join, all under Settings → Community admin → Welcome. The relay decides everything: it designates the channel, enrolls new members and validates the title and message. The client reads relay-signed state and sends narrow admin commands.

This is a draft. Tests are written but have not been run locally. The feature has only been exercised in the browser dev host against a local relay on the companion branch, for the demo recordings. See Validation status.

Companion block/buzz PR: block/buzz#9108 (relay branch primary-welcome-channel).

What it does

  • Which channel opens. When no channel was explicitly requested and nothing is saved, Messages now opens the community's welcome channel, if the viewer is in it, before the existing fallbacks. The order is:

    1. an explicit request (deep link, notification, buzz://channel/…, the empty route)
    2. the saved selection
    3. the welcome channel (new)
    4. the first auto_join channel
    5. the first non-Solo channel

    Steps 4 and 5 wait while any listed channel's metadata is pending, since that channel could be the welcome channel. If one of them opens while no welcome channel is listed, the choice is provisional for the opening window (see Round 3). The choice is saved as the selection, so the welcome channel opens once per device, not on every launch. Someone who has left the welcome channel falls through to the next step. The client never joins anyone to it.

  • Welcome card, pinned above the composer (5cc1261b7, replacing the card at the start of the history). In a busy community a card at the start of the history sat hundreds of messages up, so it is now pinned over the end of the welcome channel's history, directly above the composer, in the slot a preview's ChannelJoinNotice uses. Like the notice, it reports its height so --channel-preview-inset grows the history's end and the last messages scroll clear. A long message scrolls within the card, and the scroller becomes keyboard focusable only when it overflows. It is a region named by its title: the custom title, or the default ("Welcome to {community}" only for a configured name, else "Welcome"). The message renders through WelcomeMessage as before, with links, chips and the conversation's renderers. It never takes focus on mount. Dismiss hides it and hands focus to the composer if focus was in the card. An empty welcome channel shows the card and its usual "No messages yet.". ChannelBody no longer passes start, and the timeline's start slot (the start prop, atStart, the .edge[data-start] rule and the ungated edge-delta block in measure()) is restored to main's content (7c1cb2429). The card sits above the "X is typing" lane: the pinned slot's block-end padding is space-3 plus the pill's lane (space-half + 2 × space-1 + a caption line), reasoned from the CSS and included in the reported height; it keeps floating-surface with --floating-inset set beside its padding (ab7327b23). The card shows only while all four of these hold:

    1. The open channel is the welcome channel (relay-signed 39000 ["landing"], a stream), and the viewer is a member with its composer.
    2. This device has a first-open time T for the community. It is written the first time Messages opens the community, and on existing installs the first time after the upgrade. It lives in 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 − 600 s. One snapshot read (the session reader, then the signed HTTP POST /query bridge on both hosts) sends exactly {"authors": [viewer], "until": T - 600, "limit": 1}, with no kinds and no #h. The answer is cached per origin and viewer under welcome-earlier-activity, so a second identity on the device asks for itself. The session reader and the browser broker admit exactly this shape and no other filter without kinds or IDs (e3e15cdfc), and only about the viewer: the reader requires the author to be the transport's viewer and the broker its own identity's pubkey (ab7327b23). A kept answer is read during render, so a cached card is in the first commit with the channel; the read runs only without an answer and never retries within a mount. The native host forwards /query bodies unchanged, so it needs no new route.
    4. The card has not been dismissed on this device: welcome-dismissed:<channel id>, scoped by origin alone. An admin's later title or message edit never brings it back; moving the welcome to another channel is a new welcome, which can show once more there to a member this device classes as new (product decision; documented in channels.md, buzz-help.md and the moderation README). Leaving or removing the community forgets T and the dismissals with the rest of its device state: purgeCommunityDeviceState now clears the origin's welcome- keys (clearViewScope takes a key prefix), which the origin:viewer purge never reached.

    Nothing shows while the read is pending. A failed read (refused, error, timeout, offline, no session) shows nothing, caches nothing, and is asked again on the next visit, so existing members are never shown the card because of a network failure. These trade-offs are accepted: lurkers with no events see it again on each new device, dismissal is per device, and lurkers already in an adopted welcome channel such as #general see it once per device after the upgrade.

    ⚠️ Relay dependency. A released relay refuses this filter: p_gated_filters_authorized (crates/buzz-relay/src/handlers/req.rs) treats a filter without kinds as able to match p-gated kinds and admits it only with #p=[self], so POST /query answers 403 (api/bridge.rs) and a WebSocket REQ is CLOSED restricted:, every read fails and the card stays hidden. The companion branch now exempts reads whose authors are only the authenticated pubkey: block/buzz 7f6bca943 ("admit authors=[self] reads past the #p gate"), in WIP feat(relay): community landing channel and welcome message buzz#9108. The card needs a relay with that change; the client's gate matches it by binding the read to the viewer.

  • Markdown message (round two). The message is Markdown source, rendered through the shared MessageMarkdown (GFM, soft line breaks, no HTML) in both the timeline card (ChannelBody, through useConversation() as ChannelDescription does) and the Settings preview. buzz:// channel and message links open in place; an admin pastes them from a channel's Copy link. [@Name](nostr:npub…) identity links are display-only profile chips: they open the profile from the timeline but notify nobody, and the preview shows the same chip inert. That inert chip is opt-in through a new MessageMarkdown prop, inertProfileChips, which only WelcomeMessage passes; other non-interactive callers keep showing the locator as before. Round three replaced the round-two textarea with the composer's editor; see Round 3.

  • Opening and recovery states. While the channel list or its metadata is still loading, Messages shows Opening your channels… instead of the blank selection view. An empty list keeps that state for up to 10 s, once per community visit, because enrollment can finish after the list answers. A channel that arrives meanwhile opens at once. After the wait, an empty state offers Try again. It moves focus to the history pane, re-reads the channel list with a strong read (it does not reconnect a healthy session) and waits again.

  • Settings → Community admin → Welcome. Shown to owners and admins (from the relay-signed 13534 roster) on any relay. The former NIP-11 gate on the card is gone (welcome.ts deleted): on a relay without buzz-landing-channel the card says so and shows only the starter list.

    • Each time it opens, it reads the designation from a fresh NIP-11 document, then reads the title and message with a strong 39000 read.
    • Welcome channel picker: public, unarchived stream channels, merged from the directory and the viewer's joined list, each with its # icon (channelIcon with ChoiceRow leading; Combobox.Control leading for the chosen value). If no channel is designated yet, the field starts empty and Save stays disabled until one is chosen.
    • Title (round two): pre-filled with the custom title or the default. 1–120 Unicode scalar values on one line: no control characters and no U+2028/U+2029. Because the default depends on who is looking, it is never inferred from the text: an untouched title resends what is published, an edited title is sent as written, and only Restore defaults sends "".
    • Message: starts with the custom text or the default. It checks the relay's rules as the admin types: 1–4,000 Unicode scalar values, and no control characters other than newline and tab. CRLF is normalized, and the text is trimmed using Unicode White_Space, which matches the relay's str::trim. Text equal to the default is saved as "use the default" (welcome="").
    • A live preview renders the same WelcomeCard from the draft, with its links inert.
    • Restore defaults resets the title and message in the draft, Cancel resets the draft to the published state, and Save publishes the full state. If the relay refuses with invalid:, its reason is shown and the draft is kept.
  • Also add new members to (round two, reshaped in round three). Below the form, the starter list shows the welcome channel as a fixed first row, "Welcome channel · always joined", with its own auto_join flag left untouched, followed by only the channels whose auto_join flag is on, each with Remove. Add a channel searches the remaining open, unarchived stream channels (directory rows merged with the joined list). Each add or remove publishes auto-join-on/auto-join-off through the existing lifecycle.run at once; one change runs at a time, and Save waits for it. A joined channel's row follows the roster; for a channel the viewer has not joined, the card keeps the confirmed state itself until the directory row agrees. Errors show per row (relay refusal, or "Saved, but not confirmed yet. Try again to check."). The copy explains that adding a channel affects people who join later, and in open communities also returning people who were never in it. This replaces the per-channel "Starter channel for new members" switch in Channel settings, which is removed along with the channels plugin's adminAccess/membership plumbing; the Archive and Make private warnings now point to Settings → Community admin → Welcome.

  • Relaxed flaggable(). The lifecycle owner no longer requires the viewer to have joined a channel before flagging it, so admins can toggle starter channels they aren't in. The readback feeds discovery only for channels the viewer can access (canAccess), so the Settings card holds the confirmed state for the others.

  • Check again for unconfirmed saves. If the strong reread never shows a published save, the form keeps the draft, disables Save and offers Check again. It does not invite a blind republish. Check again re-reads NIP-11 and the strong 39000 while the form stays visible and the button stays focused and busy. If the change did land, the form shows the "Welcome saved" toast and returns focus to the message field. If it didn't, Save reopens.

  • Host validators. The browser dev broker (channel-lifecycle-protocol.ts, shared with the TS native path) and the Rust validator (src-tauri/src/relay/channel_writes.rs) accept only the exact four-tag welcome command on the channel-lifecycle route (the three-tag form from round one is no longer admitted). Other values, extra tags, other routes and altered or foreign signatures are rejected. Native users need a rebuilt desktop binary; updating the frontend alone does not update the Rust validator.

Round 3

The six commits after ebaeae8 (2a540c7dc, 1be8f076e, 97fff3051, 6d45e0f35, de4f2ff9c, f8f8275c5):

  • Starter channels scale to large communities. The starter list no longer shows a switch for every channel. It lists only the welcome channel (fixed, always joined) and the channels already flagged auto_join, each with a per-row Remove. An Add a channel search covers the remaining public channels. A channel being added is listed as "Adding…" while the relay confirms it, and a removed one stays, busy, until the relay confirms. One change runs at a time, Save waits for it, and a refusal shows beside the row (removal) or on the search (addition). Both pickers, Welcome channel and Add a channel, cap browsing with an empty query at 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, so the pane holds up for communities with thousands of channels. The 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.

  • The welcome message uses the composer's rich text editor. The new WelcomeMessageEditor (src/bundled/moderation/WelcomeMessageEditor.tsx) puts EditableInput together with ComposerFormattingTools (toolbar and shortcuts) and ComposerLinkDialog, and serializes to Markdown with composerMarkdown, so toolbar or typed formatting is written as the composer sends it. Pasted channel, message and identity links show as chips. Enter adds a line break, and there are no mentions, attachments, GIFs or cards. The live preview, character count, validation, Restore defaults, Cancel and Check again work as before. In 2a540c7dc no shared composer files changed. Since 97fff3051, EditableInput has one opt-in hook, pastedText (see the npub paste below).

    • Known limitation: published Markdown reopens as its source text, as when you edit a sent message. It round-trips unchanged, and formatting one word changes only that word.
    • Follow-up: @ and # autocomplete in the editor. Plain #name/@Name references and :shortcode: emoji are not chips in the editor, so the preview can show a reference that the editor leaves as plain text.
  • Announcements and lint. Adding or removing a starter channel shows an Added #name / Removed #name success toast, the same ToastNotice Save uses, which Sonner announces politely; the next change clears it. "Loading channels…" and the empty line are status regions again. Biome passes for the moderation plugin (biome check --error-on-warnings src/bundled/moderation on 1be8f076e), and the help guide mentions the 50-channel browse limit.

  • The welcome channel opens on a fresh load (97fff3051, 6d45e0f35). Before this, a welcome channel whose 39000 arrived after an auto_join channel's could lose to the fallback, and the starter channel opened and stayed. Two changes fix that:

    • The starter and first-channel fallbacks now wait while any listed channel's metadata is pending, because that channel could be the welcome channel.
    • A new member's membership hints can confirm their starter channel on one read and their welcome channel on a later one. So a starter or first channel chosen while no welcome channel is listed is provisional. A welcome channel that arrives within the 10 s opening window (ENROLLMENT_WAIT_MS from the list first loading) replaces it. It reopens in place, as a replace that keeps the visit's focus, so there is no Back entry to the fallback. The fallback stays if the reader has acted first: any click or key press in the window (in the workspace only, before de4f2ff9c), or any other visit. After the window nothing changes.

    The metadata wait stays because Messages cannot see NIP-11's landing_channel: RelaySession (FOUNDATION) does not project it. docs/channels.md describes the rule.

  • The welcome channel picker keeps its browse cap with a channel selected (97fff3051). Until something is typed, a Base UI Combobox showing the selected name lists items instead of filteredItems, so the picker could list every channel. The cap is now also set as the Combobox limit (50).

  • A pasted npub becomes a profile chip (97fff3051, 6d45e0f35). Pasting npub1… (bare, as Copy npub writes it) or nostr:npub1… into the welcome editor inserts a display-only [@Name](nostr:npub1…) identity link. The name comes from the profile directory, or a shortened npub when the name is unknown. Like every identity link in the welcome, it notifies no one. The npub stays as pasted when it lands inside a Markdown code span or link, in toolbar code, or right after a [label]( still being typed. Typing [@Alice](, pasting nostr:npub1… and typing ) gives one valid link. This uses the new opt-in pastedText hook on EditableInput, which receives the pasted text and where it lands (draft-text offsets and the source). The hook is skipped for raw pastes and code blocks, and when the caret's marks include code or a link. WelcomeMessageEditor is its only caller; the message composer does not pass it, so the composer's paste behaviour does not change.

  • The Settings preview draws channel links as the card does (97fff3051). The preview now renders through the conversation renderers, so a pasted channel link shows as the linked channel name with its icon. To supply them, the moderation plugin now also injects the conversation service (inject: ["relay", "settingsCards", "adminAccess", "conversation"]), and registration.test.ts provides it.

  • The welcome switch replaces only its own visit, and the npub paste needs word boundaries (de4f2ff9c, the follow-up to the review of 6d45e0f35).

    • The provisional switch to the welcome channel replaces only the visit that chose the provisional channel, checked by history entry id as setMembersOpen does. An open Settings page, or a channel the reader has just opened from the sidebar, is never replaced. Before, the switch could close Settings, or App's departure guard could ask to discard edits, with no user action. An unrouted workspace has no visit to match, so it skips the switch while Settings is shown.
    • Any click or key press anywhere in the window settles the default. While the choice is provisional, capture-phase pointerdown and keydown listeners on document settle it; they are removed when it settles or the page unmounts. Tabbing through the sidebar or typing in a field outside Messages now counts, not only input in the workspace.
    • If focus is outside Messages (and not on the body) 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 whitespace or the start or end of the draft is on both sides of it, in addition to the code and link checks. This covers an unclosed backtick and completing a bare URL (https://primal.net/p/ + paste), and replaces the [label]( regex. Typing [@Alice](, pasting nostr:npub1… and typing ) still gives one valid link.
    • Tests, written but not run: confirmWelcome now ends with an act flush, and the positive case asserts the history replace right after it, so no Back entry leads to the starter channel. Negative cases also assert the history entry is untouched. New cases cover 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.
  • A provisional welcome choice survives the host's route handoff (f8f8275c5, the follow-up to the review of de4f2ff9c). A starter channel chosen while a routed visit is still pending is resolved into that visit, and the host's handoff then passes Messages no navigation for one commit before restoring the same history entry. The render-time settle compared the choice's entry with that missing navigation and settled it, so a welcome channel confirmed later never replaced the starter channel. The settle now requires navigation to be present and to name a different entry. The switch still replaces only the provisional visit while it is shown, so Settings and later visits stay protected. A new routed positive test, written but not run, lists the starter channel while the routed visit is pending, re-renders without navigation and then with a conversation for the same entry, and asserts that the welcome channel replaces the opening with no Back entry. It was traced, not run, to fail on de4f2ff9c.

  • Tests are written but not run locally, per instruction (the machine was overloaded). WelcomeSettings.test.tsx adds or changes cases for: toolbar formatting and a pasted channel link written as Markdown; published Markdown opening as source and sent back unchanged; bolding one word inside published Markdown; the list showing only starters with the welcome channel first; the 50-channel browse cap, hint and search past the cap in both pickers, including while the welcome picker shows the selected channel; adding (listed as "Adding…" while confirmed) and removing (row kept until confirmed) through the channel lifecycle, with their toasts; failed removal beside its row and failed addition on the search; starter changes held while the welcome saves; channel links in the preview; and npub pastes. Those cover a known name, a short key for an unknown name, prose, a link destination being typed, backticks and toolbar code. ChannelsPage.tabs.test.tsx adds four cases:

    • waiting for the welcome channel's metadata before opening an auto_join channel listed first
    • opening a welcome channel confirmed after the starter channel
    • keeping the starter channel once the reader clicks in it
    • keeping the starter channel when the welcome channel arrives after the window

Relay contract this depends on

Surface Shape Client use
NIP-11 supported_extensions includes buzz-landing-channel; landing_channel: <channel uuid> Decides whether the welcome form (not the card) is shown, and gives the current designation for the form
Relay-signed kind 39000 Bare ["landing"] while the channel is designated; ["welcome", "<markdown>"] only while the message is custom; ["welcome_title", "<title>"] only while the title is custom; bare ["auto_join"] while a channel is a starter Navigation precedence, the card's title and text, and the starter switches, read through the existing discovery path (the directory projection now carries autoJoin). welcome/welcome_title without landing are ignored
Kind 9002 (admin write) content: "", exactly [["h", <channel>], ["landing", "true"], ["welcome", "<text>" or ""], ["welcome_title", "<text>" or ""]] One full-state, idempotent save on the channel-lifecycle route, confirmed by the existing strong 39000 reread. "" means the Buzz default for that part
Kind 9002 (admin write) [["h", <channel>], ["auto_join", "true" or "false"]] Each starter switch, from community owners/admins whether or not they are in the channel; confirmed by the republished 39000

Limits: welcome is 1–4,000 scalar values, not blank, no control characters except \n and \t; welcome_title is 1–120 scalar values, not blank, no control characters and no U+2028/U+2029.

The relay owns enrollment: new humans are added to the welcome channel at their first entry, before the starter channels. Moving the designation never adds people who are already in the community. The relay also refuses archive, delete, make private and expiry on the current welcome channel.

Older relays. With no landing tag and no extension, navigation works as it does today (first auto_join, then first non-Solo), no card appears, and the Welcome settings card shows only the starter list. The one visible change is that the opening state now replaces the blank view while the list loads, and an empty default waits once before showing Try again.

Overlapping PRs

Decisions taken

  • Wire shape: the designation, message and title live on the designated channel's relay-signed 39000, plus the NIP-11 advertisement for the admin form. There is one four-tag 9002 command. Navigation needs no extra read and no change to the FOUNDATION file relay/session.ts.
  • Opening state: it covers any visit whose default list is empty, rather than detecting a fresh join, so no marker, timer owner or FOUNDATION edit was needed.
  • Default text: Restore defaults followed by Save publishes "use the default" ("") for both parts rather than freezing today's copy. The default title is never inferred from an untouched or typed title.
  • Markdown source, edited in the composer's editor: the welcome is stored and sent as Markdown. Round three edits it in the composer's EditableInput with its toolbar and link dialog (round two used a textarea), with a live preview. The only shared composer change is the opt-in pastedText hook, which the composer doesn't use. Identity links are display-only chips with no notification. Channels and messages are linked with buzz:// links or bare #name.
  • Starter channels in one place: the per-channel switch is gone; the Welcome card's list is the only place to set them, on any relay.
  • One PR: the plan's read path (PR A) and write path (PR B) are combined here.

Known design gaps

  • Per-row error placement. A starter row's error is a buzz-field-error paragraph rendered after its PreferenceRow inside the SettingsGroup, as BestieTab does; the shared row has no error slot. Left as-is for this round.
  • Heading levels inside the card. Markdown headings in the welcome render at the levels MessageMarkdown gives message bodies, with no offset under the card's own heading. Left as-is for this round.

Deferred

  • Unsaved-changes guard for the Welcome form. Contributed Settings cards unmount when you switch sections and have no unsaved-departure hook. Only Profile has one, through App.tsx, which is FOUNDATION. In v1 a draft is dropped when you leave the section. The follow-up would pass an unsaved-change callback to cards in Settings.tsx and make the App.tsx prompt generic. It needs explicit approval because it edits a FOUNDATION file.
  • Disabling protected-channel controls in the UI. Archive, Delete, Make private and expiry are not disabled for the welcome channel. The relay refuses them, and the refusal appears through the existing lifecycle error copy.
  • Real-engine check of the pinned card's geometry. The card's lane above the typing pill and the history's grown end are reasoned from the CSS and covered only by jsdom tests, which cannot prove layout. Still owed, in Chromium and WebKit or the native app: a fresh profile opens the welcome channel with its card, a second identity types, and the "X is typing" pill stays visible below the card while the last message scrolls clear of it, in both themes and with enlarged text. The former start-slot scroll-anchoring journey is no longer owed: that code is restored to main (7c1cb2429). Browser cases added or removed in this PR: none.
  • @/# autocomplete in the welcome editor (see Round 3).
  • Custom emoji in the welcome are not rendered, and the message is still trimmed at both ends.
  • Mobile. Not in scope.
  • Also not in scope: a "Browse channels" action in the empty state, and a buzz-mcp flag.

Validation status

  • CI is green at 49019835b (run 42244417480): JavaScript 1–6/6, Browser measurements, Browser journeys (chromium 9/9, webkit 14/14), Flake check (Vitest 2/2, chromium 8/8, webkit 8/8), Rust and tool integration (including lifecycle_accepts_only_the_exact_landing_command), Native browser fixture, Protected agents and WebKit recording (macOS), and CI required. Windows native validation was skipped by the workflow, as on earlier runs. This is the first run on this branch in which Vitest ran: every earlier run stopped at Biome in pnpm check.
  • What it took (592281e5c, 49019835b; details in their messages): a Biome format fix in channel-lifecycle-protocol.ts; the browser policy-relay fixture now answers /api/join-policy with a 404, since Messages' welcome read goes through the broker's info route, which fetches the join policy after NIP-11, and the fixture asserted every bodyless GET was NIP-11 (470 of the 472 journey and measurement failures); and test fixes for the first Vitest run: stable snapshot fakes, the message editor's value set inside act, a scoped selector, Base UI's open list hiding the pane from role queries, history bounds in the ChannelsPage harness, both cards registered natively, and the purge's welcome store in the expected failures. Code changes in that commit: the moderation README keeps one "Getting started" section, and the purge names its welcome store "welcome record" in its warning.
  • Local runs: WelcomeSettings.test.tsx and WelcomeCard.test.tsx (49 tests) and the ChannelsPage welcome-channel test passed on this machine; tsc --noEmit and Biome passed locally at 49019835b, and design:check at 71782fcf5; CI's pnpm check ran all three at 49019835b. Everything else ran only in CI. Commits and pushes used --no-verify, so no hook checks have run.
  • FOUNDATION file touched: src/features/relay/session.ts has a one-line change in 217c1e1e5, wiring the channel store's acceptDirectory into the lifecycle capability so a confirmed starter-flag readback updates the cached directory. It needs explicit human sign-off.
  • Manual use so far is limited to the demo recordings: the browser dev host against a local relay built from the companion branch, at de4f2ff9c. Not tried natively, and not tried against any other relay.
  • Agent review: two rounds ran on dd2c58c and bbccebc and were addressed in bbccebc and 14b8831. The first human review round was addressed in be1258d, and its follow-up review in ebaeae8. Those two commits have not been re-reviewed. Round three's agent review ran on 2a540c7dc and was addressed in 1be8f076e. A review of 97fff3051 (npub paste in code or links, and a welcome channel that arrives later) was addressed in 6d45e0f35. The review of 6d45e0f35 (the automatic welcome switch could close Settings without warning, the switch could replace a channel the reader had just opened, and the provisional-switch tests lacked a completion barrier) was addressed in de4f2ff9c. The fail-then-pass runs that review asked for have not been done. The review of de4f2ff9c (a route handoff could cancel the welcome switch, and the routed positive case was missing) was addressed in f8f8275c5, which has not been re-reviewed.
  • Demo videos: admin and join recordings at de4f2ff9c exist locally and are not attached. In the join recording a new member opened in the welcome channel, with the starter channel in the sidebar. Both channels arrived together, so the provisional switch was not exercised.

Tests added or changed (colocated):

  • landing.test.ts, discovery-landing.test.ts, store.test.ts, store-discovery.test.ts: tag parsing including welcome_title, NIP-11 parsing, the text and title rules, autoJoin in the directory projection
  • channel-lifecycle.test.ts, relay-broker-api.test.mjs, channel_writes.rs, native-api.test.ts: the exact four-tag command shape, confirmed, refused and unconfirmed outcomes, flagging an unjoined channel, and broker and native admission
  • MessageMarkdown.test.tsx, WelcomeCard.test.tsx: Markdown rendering, the interactive identity chip, the inert chip behind inertProfileChips, the default and custom titles
  • ChannelsPage.tabs.test.tsx: precedence, waiting for pending metadata, a provisional default replaced by a later welcome channel, including through a routed visit's handoff (and kept after a click, after a key press outside Messages, or after the window; never replacing Settings, routed or unrouted; focus outside Messages kept through the switch), the opening, empty and wait states, Try again, and the Markdown card with a custom title
  • welcome-pin.test.tsx, device-state.test.ts, reader.test.ts, relay-broker.test.mjs: the four card conditions, a kept answer in the first commit, no retry within a mount after a failed read, dismiss in one channel then the welcome moving to another and back, the purge forgetting only the origin's welcome keys, and another author's kinds-less read refused by both gates
  • WelcomeSettings.test.tsx, registration.test.ts: the form gates, title and message rules, Restore defaults and Cancel, the untouched-title rule, refusal, Check again, the starter list (fixed welcome row, only flagged rows, Remove and Add a channel, busy and error states, toasts, the unsupported relay), the 50-channel browse cap in both pickers (also with a channel selected), the message editor's Markdown round trip, npub pastes in prose, code (including after an unclosed backtick), link labels and destinations, and a bare URL being completed, channel links in the preview, and card visibility by role
  • ChannelSettingsPanel.test.tsx, ChannelGeneralAccess.test.tsx, ChannelNavigationState.test.tsx: the removed switch and the new copy

Checks still to do

  • CI green on the merged tree (run 42244417480 at 49019835b); of the affected test files, only the WelcomeSettings, WelcomeCard and one ChannelsPage test ran locally
  • Light and dark themes for the card, the opening and empty states, and the Welcome form and starter list
  • Narrow and wide widths, and 200% text
  • Keyboard and pointer use of the channel picker, title field, message editor (toolbar, shortcuts, link dialog), starter Remove buttons and Add a channel search, and the action row, plus focus after Save, Check again and Try again
  • Scroll anchoring when the card enters or leaves the edge, in both Chromium and WebKit (the journey described above)
  • An agent exercises both flows against a local relay running the companion branch: an admin sets the welcome and a starter channel, and a new member joins through an invite and lands in the welcome channel
  • An admin reopens published Markdown in the message editor and confirms it round-trips, including after formatting part of it
  • The starter pickers with a community of more than 50 channels, including the welcome picker with a channel selected
  • A new member whose starter channel is confirmed before their welcome channel lands in the welcome channel, a click in the starter channel first keeps them there, and Settings opened during the opening window stays open
  • Pasting a copied npub into the welcome editor in prose, inside code, after an unclosed backtick, inside a link being typed and completing a bare URL; the message composer's paste is unchanged
  • A native rebuild for the Rust validator change
  • A human tests both flows
  • The review-completed marker is not set; add it only after the steps above hold

Size

About 3,000 added and 290 removed production lines against main (including CSS and the host validators), at ab7327b23. Round two added Markdown rendering, the title, the starter list and the validators, and removed the per-channel switch and welcome.ts. Round three added about 695 and removed about 150 of those lines, for the searchable starter list, WelcomeMessageEditor, the default-open fix and its visit-scoped switch, the npub paste and pastedText hook (25 lines in EditableInput.tsx), and the preview's channel links. 97fff3051 and 6d45e0f35 together add 175 and remove 32; de4f2ff9c adds 60 and removes 31, in ChannelsPage.tsx and WelcomeMessageEditor.tsx. The pinned welcome (5cc1261b7, e3e15cdfc) and its review follow-ups (7c1cb2429, ab7327b23) net about 350 added production lines: welcome-pin.ts, the card's pin, the gate and the purge, less the timeline's start slot now restored to main. WelcomeSettings.tsx is 1,071 lines and WelcomeMessageEditor.tsx 277. Tests are about 3,695 added and 405 removed lines, and docs about 275 added and 40 removed.

🤖 Generated with Claude Code

@matt2e
matt2e force-pushed the primary-welcome-channel branch from 70db094 to 1413081 Compare November 6, 2026 13:23
matt2e added a commit that referenced this pull request Nov 7, 2026
…ead of an inert Load older button (#2810)

## Summary

When Buzz can't reach the relay, it shows the conversations saved on
this device. In a busy channel, the saved copy is one page of a longer
history, so the top of the conversation still offered an enabled **Load
older messages** button. Pressing it did nothing: no read, no spinner
and no error. The cached-only store reads nothing, and its `loadOlder`
returns before any request. In Solo chats, scrolling to the top did
nothing in the same way.

Now that spot reads "You can load older messages once Buzz reconnects."
When the connected session replaces the saved one, the button or scroll
paging comes back. No relay reads are added.

## What changed

- **Contract** (`src/features/relay/contracts.ts`): `ChannelQueries`
gains an optional `cachedOnly: true`. It marks a store that reads
nothing until a connected session replaces it.
- **Store** (`src/features/relay/store.ts`): the cached-only store
publishes `cachedOnly: true` where it already withholds the `refer`
lookup.
- **Timeline** (`src/features/messages/ChannelTimeline.tsx`): after the
history-limit check, a window with `hasMore` in a cached-only session
shows the caption instead of the button. This applies in button mode
(Channels) and scroll mode (Solo).
  - The history-limit caption still takes precedence.
  - A window that has all of its history saved shows nothing new.
  - The swap to the connected session doesn't remount the timeline.
- **The trigger is the store's cached-only fact, not "the socket is
down".** A connected session that loses its socket can still page over
HTTP, so it keeps the button. `ChannelBody`'s `cached` prop is also true
while connected, whenever a saved channel list is waiting for
confirmation. Using it would show the caption by mistake.
- **Help** (`docs/buzz-help.md`): a new **Offline** bullet that matches
the caption.
- **Tests:**
- A `ChannelTimeline.viewport.test.tsx` case runs real cached-only and
connected sessions through the handoff. Offline, the caption shows in
both modes with no button. The connected session then brings the button
back without remounting the timeline.
- A `ChannelTimeline.test.tsx` case drives the `setup()` harness through
the swap under a stable `continuityKey`, as `ChannelBody` passes. A
top-edge gesture made offline sends no older read. The connected session
sends exactly one, on the shared transport, once its head verifies. The
harness gains a `continuityKey` option, `swap()` and `caption()`.
- **Size:** the production change is 4 files, +10 −2. It adds no new
state, props, timers or relay reads.

**Why "can load" rather than "load":** the timeline keeps a top-edge
gesture made while offline, and the connected session pages once it
verifies the head. Without that gesture nothing pages by itself, because
verification alone never pages a restored top (this is deliberate and
pinned by an existing test). In that case Channels shows the button
again and Solo waits for a scroll. "You can load older messages once
Buzz reconnects." is true with or without a kept gesture. So the caption
appearing is not treated as a request to page, and paging behaviour is
unchanged.

## Before/after

This recording runs the same script twice in Chromium against the repo's
browser fixture relay. Main (3bcf1c4) is on the left and this branch
is on the right. #Alpha has 120 messages and is relaunched offline. On
main, **Load older messages** stays enabled through two presses and
nothing changes. On this branch, the same spot shows the caption. After
**Retry connection**, both builds load older messages the same way.


https://github.com/user-attachments/assets/d00ca6c6-f535-49bf-8c53-dacce8872adc

Still at 0:10, offline, scrolled to the top of #Alpha. Left: main shows
the enabled **Load older messages** button. Right: this branch shows
"You can load older messages once Buzz reconnects." in its place.

![Offline, at the top of #Alpha: an enabled Load older messages button
on main (left) and the reconnect caption on this branch
(right)](https://github.com/user-attachments/assets/14596745-bff3-4c51-86b6-7cb1ab28874a)

## Testing

These checks ran on 988f8fc91, which has the same tree as this squashed
commit.

- **Fail-before:** the new viewport case fails on main at
`ChannelTimeline.viewport.test.tsx:902`. The caption isn't found, and
the DOM shows the enabled button.
- **Vitest:** 346 tests pass across the four `ChannelTimeline*.test.tsx`
files, `relay/startup.test.ts` and `channel-references.test.ts`.
- **Static checks:** `bin/pnpm typecheck`, Biome and `design:check` are
clean.
- **Browser (Chromium, fixture relay):**
- Offline, the caption shows with no button and sends no `/query`
request.
  - After **Retry connection**, older messages load.
  - The dark theme looks correct.
- At 200% text, the caption wraps inside the edge: 2 lines at 600 px and
4 lines at 390 px.
- No browser journeys were added or removed.

## Not validated

- WebKit with the final copy
- The native desktop app
- Solo, Bestie and conversation tabs in the app (scroll mode is covered
by Vitest only)
- A screen reader

## Related

- #2652 and #2623 touch the same files. Merging either with this one is
trivial.
- **Existing gap after reconnect, not fixed here:** until the
reconnected channel list verifies, **Load older messages** is enabled
but a press does nothing. The press isn't kept the way a scroll gesture
is. This predates this change and is a candidate follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
baxen added a commit that referenced this pull request Nov 7, 2026
…2904)

## Summary

A brand-new community has no channels, so its owner lands on a blank
Messages page ("Select a channel to read it.") after onboarding. Until
the relay creates a landing channel (block/buzz#9108), this PR has the
owner's client do it, and nothing more.

`useFirstChannel` (`src/bundled/channels/first-channel.ts`), called once
from `ChannelWorkspace`, acts only when **all** of these hold:

- the session is live (not cached), the channel list is `ready`, and the
viewer has joined no stream or forum channel (Solo chats and DMs don't
count);
- the community roster says the viewer is the community **owner**
(admins and members never act);
- `channels.anyChannel()` (new: `{kinds:[39000], authors:[relay],
"#t":["stream","forum"], limit:20}`, Solo-labelled streams skipped)
finds **no** stream or forum, open, private or archived. DMs, Solo chats
(the Bestie tab opens one first) and system channels don't count. A
failed check does nothing and retries next launch. A full batch of 20
Solo chats counts as not empty: it creates nothing and settles the
marker, so clients stop checking.

It then creates one open `general` through the existing
`channelCreation.create` and flags it as a starter channel with
`auto-join-on`, so the relay (block/buzz#8594) joins new members to it.
Messages opens it through its existing first-channel fallback (#1624).

**Not spamming general channels:**
- The relay's unique open channel names (block/buzz#8787) refuse a
second `general` in the same community.
- A settled view-state marker per community on each device means that
device never checks again once the community has a channel.
- At most one attempt per community per app run. A failure (for example,
the name is already taken) waits for the next launch instead of retrying
on every render.
- It never joins anyone, adds members or posts a message.
- Relay load: the community roster (`13534`, `limit: 1`) is read only
for a viewer with no channel to open, so ordinary clients make no new
reads. The channel check is a single read capped at 20 events, never a
directory scan.

Non-goals: welcome messages, landing-channel designation, server-side
provisioning (block/buzz#9108 / #2623).

## Testing

- `first-channel.test.tsx` (new, jsdom + RTL `renderHook`):
- the owner of an empty community gets exactly one open `general` plus
`auto-join-on`, and a later mount, even in a new run, doesn't read or
create again;
- a community where the relay already holds a channel settles without
creating, and a failed check creates and settles nothing;
- `anyChannel` sends exactly the capped `#t` filter, ignores Solo chats,
counts an archived stream, and treats a full batch of Solo chats as not
empty (store test);
  - admin, member and missing roles do nothing;
- a joined channel, a loading list or a cached session do nothing and
don't read the roster;
  - a refused create isn't retried in the same run.
- Fail-then-pass: loosening the owner check fails 3 cases.
- Full `vitest run` locally: 1022 files / 19379 tests pass; typecheck
and `biome check` clean.
- `vitest run src/bundled/channels src/bundled/moderation`: all pass,
including the existing starter-channel `ChannelsPage` cases. `tsc
--noEmit` and the pre-push hook suite pass.

**Deferred:**
- No agent run against a live relay yet.
- Human test: create a community in onboarding, open Messages, and
#general should appear and open. Reload, and nothing else gets created.
A second member joins and lands in #general.
- Browser suite left to CI.

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
@matt2e
matt2e force-pushed the primary-welcome-channel branch 2 times, most recently from f509285 to 99e32d5 Compare November 9, 2026 10:39
matt2e and others added 21 commits November 11, 2026 11:45
…ns 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ion; 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ttings

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…r 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…for the welcome

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…n Welcome settings

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…the Welcome pane gaps

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…efault; keep npubs pasted into code or links

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…andoff

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…view

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…other

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ing hold

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…s 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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:<channel id>), 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e and others added 7 commits November 11, 2026 11:46
…eave, keep the card off the typing pill

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…rd 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…lay 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 <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…lears 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 <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <origin> on this device".

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…wser fixtures again

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 87bf2d8 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 <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ine 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 `<br>` 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 <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matt2e
matt2e force-pushed the primary-welcome-channel branch from 99e32d5 to 665ba45 Compare November 11, 2026 00:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant