Repository navigation
Conversation
4 tasks
matt2e
force-pushed
the
primary-welcome-channel
branch
from
November 6, 2026 13:23
70db094 to
1413081
Compare
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.  ## 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
force-pushed
the
primary-welcome-channel
branch
2 times, most recently
from
November 9, 2026 10:39
f509285 to
99e32d5
Compare
…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>
…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
force-pushed
the
primary-welcome-channel
branch
from
November 11, 2026 00:52
99e32d5 to
665ba45
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
buzz://channel/…, the empty route)auto_joinchannelSteps 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'sChannelJoinNoticeuses. Like the notice, it reports its height so--channel-preview-insetgrows 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 throughWelcomeMessageas 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.".ChannelBodyno longer passesstart, and the timeline's start slot (thestartprop,atStart, the.edge[data-start]rule and the ungated edge-delta block inmeasure()) 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 keepsfloating-surfacewith--floating-insetset beside its padding (ab7327b23). The card shows only while all four of these hold:["landing"], a stream), and the viewer is a member with its composer.welcome-first-open, and is never replaced.POST /querybridge 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 underwelcome-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/querybodies unchanged, so it needs no new route.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 inchannels.md,buzz-help.mdand the moderation README). Leaving or removing the community forgets T and the dismissals with the rest of its device state:purgeCommunityDeviceStatenow clears the origin'swelcome-keys (clearViewScopetakes a key prefix), which theorigin:viewerpurge 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
#generalsee it once per device after the upgrade.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], soPOST /queryanswers 403 (api/bridge.rs) and a WebSocket REQ is CLOSEDrestricted:, every read fails and the card stays hidden. The companion branch now exempts reads whoseauthorsare only the authenticated pubkey: block/buzz7f6bca943("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, throughuseConversation()asChannelDescriptiondoes) 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 newMessageMarkdownprop,inertProfileChips, which onlyWelcomeMessagepasses; 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.tsdeleted): on a relay withoutbuzz-landing-channelthe card says so and shows only the starter list.#icon (channelIconwithChoiceRow leading;Combobox.Control leadingfor the chosen value). If no channel is designated yet, the field starts empty and Save stays disabled until one is chosen."".str::trim. Text equal to the default is saved as "use the default" (welcome="").WelcomeCardfrom the draft, with its links inert.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_joinflag left untouched, followed by only the channels whoseauto_joinflag 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 publishesauto-join-on/auto-join-offthrough the existinglifecycle.runat 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'sadminAccess/membershipplumbing; 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 thechannel-lifecycleroute (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) putsEditableInputtogether withComposerFormattingTools(toolbar and shortcuts) andComposerLinkDialog, and serializes to Markdown withcomposerMarkdown, 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. In2a540c7dcno shared composer files changed. Since97fff3051,EditableInputhas one opt-in hook,pastedText(see the npub paste below).@and#autocomplete in the editor. Plain#name/@Namereferences 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 #namesuccess toast, the sameToastNoticeSave 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/moderationon1be8f076e), 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 anauto_joinchannel's could lose to the fallback, and the starter channel opened and stayed. Two changes fix that:ENROLLMENT_WAIT_MSfrom the list first loading) replaces it. It reopens in place, as areplacethat 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, beforede4f2ff9c), 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.mddescribes 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 listsitemsinstead offilteredItems, so the picker could list every channel. The cap is now also set as the Comboboxlimit(50).A pasted npub becomes a profile chip (
97fff3051,6d45e0f35). Pastingnpub1…(bare, as Copy npub writes it) ornostr: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](, pastingnostr:npub1…and typing)gives one valid link. This uses the new opt-inpastedTexthook onEditableInput, 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.WelcomeMessageEditoris 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 theconversationservice (inject: ["relay", "settingsCards", "adminAccess", "conversation"]), andregistration.test.tsprovides it.The welcome switch replaces only its own visit, and the npub paste needs word boundaries (
de4f2ff9c, the follow-up to the review of6d45e0f35).setMembersOpendoes. 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.pointerdownandkeydownlisteners ondocumentsettle 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."preserve", so the welcome composer does not take it.https://primal.net/p/+ paste), and replaces the[label](regex. Typing[@Alice](, pastingnostr:npub1…and typing)still gives one valid link.confirmWelcomenow ends with anactflush, and the positive case asserts the historyreplaceright 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 ofde4f2ff9c). 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 onde4f2ff9c.Tests are written but not run locally, per instruction (the machine was overloaded).
WelcomeSettings.test.tsxadds 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.tsxadds four cases:auto_joinchannel listed firstRelay contract this depends on
supported_extensionsincludesbuzz-landing-channel;landing_channel: <channel uuid>["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 starterautoJoin).welcome/welcome_titlewithoutlandingare ignoredcontent: "", exactly[["h", <channel>], ["landing", "true"], ["welcome", "<text>" or ""], ["welcome_title", "<text>" or ""]]channel-lifecycleroute, confirmed by the existing strong 39000 reread.""means the Buzz default for that part[["h", <channel>], ["auto_join", "true" or "false"]]Limits:
welcomeis 1–4,000 scalar values, not blank, no control characters except\nand\t;welcome_titleis 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
landingtag and no extension, navigation works as it does today (firstauto_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
ChannelSummary.autoJoin, the narrow 9002channel-lifecycleroute, its strong readback, and the auto-join fallback, which now sits below the welcome channel. Its per-channel switch moves into the Welcome settings card.channel-detailsroute; it changes only one sentence of copy inChannelGeneralAccess.useChannelDirectory.Decisions taken
relay/session.ts."") for both parts rather than freezing today's copy. The default title is never inferred from an untouched or typed title.EditableInputwith its toolbar and link dialog (round two used a textarea), with a live preview. The only shared composer change is the opt-inpastedTexthook, which the composer doesn't use. Identity links are display-only chips with no notification. Channels and messages are linked withbuzz://links or bare#name.Known design gaps
buzz-field-errorparagraph rendered after itsPreferenceRowinside theSettingsGroup, as BestieTab does; the shared row has no error slot. Left as-is for this round.MessageMarkdowngives message bodies, with no offset under the card's own heading. Left as-is for this round.Deferred
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 inSettings.tsxand make theApp.tsxprompt generic. It needs explicit approval because it edits a FOUNDATION file.7c1cb2429). Browser cases added or removed in this PR: none.@/#autocomplete in the welcome editor (see Round 3).Validation status
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 (includinglifecycle_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 inpnpm check.592281e5c,49019835b; details in their messages): a Biome format fix inchannel-lifecycle-protocol.ts; the browserpolicy-relayfixture now answers/api/join-policywith 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 insideact, 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.WelcomeSettings.test.tsxandWelcomeCard.test.tsx(49 tests) and the ChannelsPage welcome-channel test passed on this machine;tsc --noEmitand Biome passed locally at49019835b, anddesign:checkat71782fcf5; CI'spnpm checkran all three at49019835b. Everything else ran only in CI. Commits and pushes used--no-verify, so no hook checks have run.src/features/relay/session.tshas a one-line change in217c1e1e5, wiring the channel store'sacceptDirectoryinto the lifecycle capability so a confirmed starter-flag readback updates the cached directory. It needs explicit human sign-off.de4f2ff9c. Not tried natively, and not tried against any other relay.2a540c7dcand was addressed in1be8f076e. A review of97fff3051(npub paste in code or links, and a welcome channel that arrives later) was addressed in6d45e0f35. The review of6d45e0f35(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 inde4f2ff9c. The fail-then-pass runs that review asked for have not been done. The review ofde4f2ff9c(a route handoff could cancel the welcome switch, and the routed positive case was missing) was addressed inf8f8275c5, which has not been re-reviewed.de4f2ff9cexist 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 includingwelcome_title, NIP-11 parsing, the text and title rules,autoJoinin the directory projectionchannel-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 admissionMessageMarkdown.test.tsx,WelcomeCard.test.tsx: Markdown rendering, the interactive identity chip, the inert chip behindinertProfileChips, the default and custom titlesChannelsPage.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 titlewelcome-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 gatesWelcomeSettings.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 roleChannelSettingsPanel.test.tsx,ChannelGeneralAccess.test.tsx,ChannelNavigationState.test.tsx: the removed switch and the new copyChecks still to do
49019835b); of the affected test files, only the WelcomeSettings, WelcomeCard and one ChannelsPage test ran locallySize
About 3,000 added and 290 removed production lines against
main(including CSS and the host validators), atab7327b23. Round two added Markdown rendering, the title, the starter list and the validators, and removed the per-channel switch andwelcome.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 andpastedTexthook (25 lines inEditableInput.tsx), and the preview's channel links.97fff3051and6d45e0f35together add 175 and remove 32;de4f2ff9cadds 60 and removes 31, inChannelsPage.tsxandWelcomeMessageEditor.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.tsxis 1,071 lines andWelcomeMessageEditor.tsx277. Tests are about 3,695 added and 405 removed lines, and docs about 275 added and 40 removed.🤖 Generated with Claude Code