diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index fee5cdbb27..a1df69bd5d 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3397,6 +3397,54 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ ["p", transport.viewer], ], }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "false"], + ["welcome", ""], + ["welcome_title", ""], + ], + }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "true"], + ["welcome", "a\rb"], + ["welcome_title", ""], + ], + }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ], + }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "two\nlines"], + ], + }, + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "true"], + ["name", "rename"], + ], + }, { ...template, content: "extra" }, { ...template, @@ -3463,7 +3511,23 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ expect(flagged).toMatchObject(flag); await transport.channelLifecycle.publish(flagged, signal); } - expect(h.publications).toHaveLength(4); + const landing = { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["landing", "true"], + ["welcome", "# Say hi\n\n\t**here**"], + ["welcome_title", "Hello"], + ], + }; + expect((await h.post("sign", landing)).status).toBe(400); + expect((await h.post("channel-details-sign", landing)).status).toBe(400); + const designated = await transport.channelLifecycle.sign(landing, signal); + expect(verifyEvent(designated)).toBe(true); + expect(designated).toMatchObject(landing); + await transport.channelLifecycle.publish(designated, signal); + expect(h.publications).toHaveLength(5); } finally { live?.dispose(); await h.close(); @@ -4151,6 +4215,29 @@ test.each([[], [1, 42], [1, 42, 43]])( }, ); +test("join discovery projects welcome-channel support and designation", async () => { + const landing = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11"; + const h = await harness((call) => + Response.json( + call.url === fixtureRelayUrl + ? { + name: "Community", + supported_extensions: ["buzz-landing-channel"], + landing_channel: landing, + } + : { policy: null }, + ), + ); + try { + expect(await (await h.get("info")).json()).toMatchObject({ + landingChannels: true, + landingChannel: landing, + }); + } finally { + await h.close(); + } +}); + test("Builderlab HTTP accepts full agent instructions and isolates the fixed native routes", async () => { let openLogin; const opened = new Promise((resolve) => { diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index b49631ad19..b7484a361e 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1,4 +1,5 @@ import { isSidebarPreferencesBatch } from "../src/features/relay/sidebar-preferences.ts"; +import { isEarlierActivityFilter } from "../src/features/relay/earlier-activity.ts"; import { isCatalogKind, MAX_EVENT_BYTES as CATALOG_EVENT_BYTES, @@ -26,6 +27,7 @@ import { } from "../src/features/repos/git.ts"; import { validateMemberAdministrationTemplate } from "../src/features/channel-members/administration-protocol.ts"; import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts"; +import { landingInfo } from "../src/features/relay/landing.ts"; import { validateDetailsTemplate } from "../src/features/relay/channel-details-protocol.ts"; import { validateArchiveRequestTemplate } from "../src/features/relay/identity-archive-protocol.ts"; import { @@ -749,7 +751,7 @@ export function validChannelActivityFilters(filters) { ) ); } -export function validFilters(filters) { +export function validFilters(filters, viewer) { return ( Array.isArray(filters) && filters.length >= 1 && @@ -767,7 +769,8 @@ export function validFilters(filters) { )) || (filter.kinds === undefined && Array.isArray(filter.ids) && - filter.ids.length > 0)) && + filter.ids.length > 0) || + isEarlierActivityFilter(filter, viewer)) && Number.isInteger(filter.limit) && filter.limit >= 1 && filter.limit <= MAX_LIMIT, @@ -1319,6 +1322,7 @@ export function relayBrokerPlugin({ name: info.name, icon: info.icon, policy: policy ?? null, + ...landingInfo(info), ...(gifSearchPath ? { supported_extensions: ["buzz-gif"], @@ -3146,7 +3150,7 @@ export function relayBrokerPlugin({ !readPublishing && !snapshot && !channelActivity && - !validFilters(filters) + !validFilters(filters, viewer) ) return json(res, 400, { error: "Read filter rejected" }); if (publishing || readPublishing) { diff --git a/browser-host/relay-broker.test.mjs b/browser-host/relay-broker.test.mjs index ac3cacf050..2466dcaf12 100644 --- a/browser-host/relay-broker.test.mjs +++ b/browser-host/relay-broker.test.mjs @@ -16,6 +16,12 @@ test("read broker accepts non-channel finite reads without relaxing filter budge { kinds: [9], "#e": ["root"], depth_limit: 10, limit: 80 }, ]) assert.equal(validFilters([filter]), true); + // The welcome card's one question: has the viewer written anything yet? + const viewer = "a".repeat(64); + assert.equal( + validFilters([{ authors: [viewer], until: 1788810000, limit: 1 }], viewer), + true, + ); for (const filters of [ [], Array(5).fill({ kinds: [0], limit: 1 }), @@ -25,8 +31,19 @@ test("read broker accepts non-channel finite reads without relaxing filter budge [{ kinds: [65536], limit: 1 }], [{ kinds: [], limit: 1 }], [{ limit: 1 }], + // Only that exact shape, about the broker's own identity, may omit kinds. + [{ authors: [viewer], limit: 1 }], + [{ authors: [viewer], until: 1788810000, limit: 2 }], + [{ authors: [viewer, "b".repeat(64)], until: 1, limit: 1 }], + [{ authors: [viewer], until: 1, limit: 1, "#h": ["channel"] }], + [{ authors: ["viewer"], until: 1, limit: 1 }], + [{ authors: ["b".repeat(64)], until: 1788810000, limit: 1 }], ]) - assert.equal(validFilters(filters), false); + assert.equal(validFilters(filters, viewer), false); + assert.equal( + validFilters([{ authors: [viewer], until: 1788810000, limit: 1 }]), + false, + ); }); test("broker signing is limited to bounded channel messages and canonical direct and nested replies", () => { diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 31ec587782..fd2459d76e 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -462,6 +462,38 @@ revokes all of them, and a pairing unused for 30 days expires. - **You don't have access to this community**: when you're signed in through the community's server, it names the account it refused and offers **Switch account**. **Check again** once an admin has added you. +- **Welcome channel**: on relays that support it, a community can choose a + public channel that new members join and open first. There, a new member + sees a welcome card above the message box, showing the community's message + or a default one. Choose **Dismiss** to hide it on this computer; it stays + hidden even if the title or message changes, though a welcome moved to + another channel can show once more there. People who had already written in the + community never see it, though someone who has never posted may see it once + on each new device. The card isn't a message: it can't be replied to or + deleted. While + a new member's channels arrive, Messages says it is opening them, and waits a + few seconds for the welcome channel rather than open another conversation + first. If none arrive, **Try again** checks for them again; a welcome channel + that arrives later still opens. Owners and admins choose the channel and edit + the card's title and message in **Settings → Community admin → Welcome**. The + message editor has the composer's formatting toolbar and shortcuts. The + default title names the community when the relay or you have given it a + name, and is just "Welcome" otherwise. The message is saved as Markdown, up + to 4,000 characters, + and a published message opens as its Markdown source; identity links name + people without notifying them. Paste a channel's **Copy link** to link the + channel, or a person's **Copy npub** to link the person. People already in + the community keep their channels when the welcome channel changes. +- **Starter channels**: in the same **Welcome** settings, owners and admins + choose which other open channels new members join, even on relays without a + welcome channel. Only starter channels are listed, each with **Remove**; + **Add a channel** searches the others. Until you type a name, this picker and + the welcome channel picker list the first 50 channels. Adding and removing + change the draft; **Save** publishes them with the welcome, and **Cancel** + discards them. A saved change applies to people who join + afterwards, not to current members; in an open community it also adds + returning people who were never in that channel. Archiving a starter channel + or making it private pauses it until it is public again. - **Finding channels**: search's **Channels** tab lists the community's open channels you can join, with their member counts; typing also matches channels you're in. Opening an unjoined channel shows a preview with **Join**. Private diff --git a/docs/channels.md b/docs/channels.md index bc7a96772e..8a6d3c1161 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -570,14 +570,29 @@ the returned command, and confirms relay-owned state before removing a row. Arch confirmed Delete/Leave use the existing access-loss purge. Commands use narrow development-broker routes or the packaged native `relay_channel_sign` and `relay_channel_publish` commands, never the message outbox or automatic replay. -The same route carries the **Starter channel for new members** switch as two commands -without a confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002` -with `auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an -active public stream channel before signing (channel roles cannot show community -ownership; the relay accepts the flag only from community owners and admins) and -confirms the result from the republished `39000`: the relay advertises the flag -as a bare `auto_join` tag only while it is in effect, so success is that tag's -presence or absence, not the written value. +The community **Welcome** settings card ([communities](communities.md#welcome-channel)) +uses the same route to add and remove starter channels, two commands without a +confirmation dialog, `auto-join-on` and `auto-join-off`, each a `9002` with +`auto_join=true|false`. The lifecycle owner requires fresh relay metadata for an +active public stream channel before signing, whether or not the viewer is in it +(channel roles cannot show community ownership; the relay accepts the flag only +from community owners and admins), and confirms the result from the republished +`39000`: the relay advertises the flag as a bare `auto_join` tag only while it is +in effect, so success is that tag's presence or absence, not the written value. +That reread updates the channel list only for a channel the viewer can access. +The card's welcome form uses the same route for the welcome channel. Each save is +one `9002` carrying the full state, `landing=true`, `welcome=` and +`welcome_title=`, each empty for its default; the relay accepts a repeat of +the current state. Before signing +and again before publishing, the lifecycle owner requires fresh relay metadata +for an active, open stream channel without an expiry. It confirms the save with a +strong reread of the target's `39000`: the relay advertises a bare `landing` tag +there while the channel is designated, plus `welcome` and `welcome_title` tags +while the message and title are custom. Neither tag counts without `landing`. The relay's `invalid:` +reason is shown with the draft kept. When the result can't be confirmed, the +card keeps the draft, disables Save and offers **Check again**, which reads the +designation and message from the relay afresh instead of republishing; a change +that did land leaves nothing to save, while a missing one reopens Save. A published lifecycle command keeps its bounded, fresh, strong confirmation when access loss retires access-dependent work. Retired confirmation reads are reread; caller cancellation, session clear/disposal and the original deadline @@ -585,11 +600,14 @@ still abort its signal. A rejected publication or definitive confirmation-read failure still fails the command; an aborted read alone never confirms it. These signals do not forcibly interrupt an in-flight native publication IPC. Both hosts admit only the exact two-tag `9002` shape with `archived=true` (Archive), -`archived=false` (Unarchive) or `auto_join=true|false` (the switch); other values, +`archived=false` (Unarchive) or `auto_join=true|false` (adding or removing a starter channel), and the +four-tag welcome shape: `landing=true`, a `welcome` of 1–4,000 characters (no +control characters except line breaks and tabs) or empty, and a `welcome_title` +of 1–120 characters (no control characters or line separators) or empty; other values, extra tags, cross-route commands and altered or foreign signatures are rejected. Hosts without this capability display -an unavailable notice. Native Unarchive and the auto-join switch require -rebuilding/restarting the desktop +an unavailable notice. Native Unarchive, starter changes and the welcome +command require rebuilding/restarting the desktop binary; updating the frontend alone does not update the Rust validator. Rust validator and production-IPC tests cover both values, but do not establish an installed desktop Archive → Unarchive round trip against a live relay; that @@ -664,23 +682,14 @@ row are omitted. Duration uses Create's Ongoing/Temporary cards. Access controls live in the members dialog, separate from the text and lifespan draft. These details controls stage changes; none publishes immediately. -**Starter channel for new members**, between Description and Members, is separate: an -immediate switch that publishes its own narrow `9002` (see -[Channel lifecycle](#channel-lifecycle)) and shows pending, the relay's refusal -or, when the change is not confirmed, a prompt to try again beside itself. Once the -channel's metadata shows the value that toggle wanted, the prompt clears, so trying -again cannot undo a change that landed. It appears only to community owners and admins -(the relay-signed kind `13534` roster) who are members of an active public stream -channel, not previewing it or a guest in it, and only once the channel is loaded -live rather than from cache. The relay checks the community role, not channel -membership, so the gate is display-only and narrower: an owner or admin must join -the channel to see the switch. -Archiving a starter channel or making it private hides the switch and pauses the -flag without clearing it: the relay stops advertising and applying it, refuses -to change it on a private channel, and resumes adding new members on Unarchive -or once the channel is public again. For a starter channel, the Archive and -**Change general access?** (to Private) confirmations say so, so an owner or -admin can turn the switch off first. +Starter channels are chosen in **Settings → Community admin → Welcome** (see +[communities](communities.md#welcome-channel)), not here. +Archiving a starter channel or making it private removes it from that list and +pauses the flag without clearing it: the relay stops advertising and applying it, +refuses to change it on a private channel, and resumes adding new members on +Unarchive or once the channel is public again. For a starter channel, the Archive +and **Change general access?** (to Private) confirmations say so and point to the +Welcome settings, so an owner or admin can turn the flag off first. **Save changes** submits the draft together and is enabled only for valid, changed values. **Cancel** explicitly discards edits and closes without confirmation. Close, Escape and backdrop clicks close untouched forms immediately; changed drafts first show @@ -1292,11 +1301,45 @@ concurrently with the real connection handshake. Saved groups, stars and channel names are display data, not a confirmed preference mutation base. No cache means the ordinary cold connection flow; unavailable/corrupt storage never grants access. -With no saved selection, Messages opens the first channel the relay flags -`auto_join` (its starter channel for new members), then the first non-Solo -channel; an explicit empty route bypasses both. The relay adds people to its -`auto_join` channels itself, and the resulting membership hints populate the -sidebar live: +With no saved selection, Messages opens the community's welcome channel if the +viewer is in it, then the first channel the relay flags `auto_join` (its starter +channel for new members), then the first non-Solo channel; an explicit empty +route bypasses all three. The welcome channel is the stream channel whose +relay-signed `39000` carries `landing`. Until every listed channel's metadata +has arrived, any of them could be the welcome channel, so the starter and +first-channel fallbacks wait. They also wait for the relay's NIP-11 document, +read when Messages has nothing saved or requested to open, and while the +welcome channel it names is not listed yet: no DM or other channel opens in its +place until it arrives or ten seconds pass from the channel list first loading. +That wait applies only while the list still looks like a new member's, with +nothing but the relay's starter channels and DMs in it; once a channel the +person joined themselves is listed, the relay is not adding them to the welcome +channel, so the fallback opens at once, as for a member from before it was +designated or one who left it. The channel opened when the wait ends is saved +as the selection, so the next opening does not wait again. A saved selection +whose metadata is still arriving needs no NIP-11 read; an archived one no +longer opens, so it does not count. The relay's membership hints can still add a new +member to their starter channel before their welcome channel, so a fallback +opened while no welcome channel is listed is provisional: a welcome channel that +arrives within ten seconds of the channel list first loading replaces it, unless +the viewer has clicked or typed anywhere in the window or opened anything else, +Settings included, meanwhile. Focus the viewer has moved outside Messages stays +where it is. +A new member also sees the [pinned welcome](#pinned-welcome) there. While the +channel list or its metadata is still loading, Messages shows **Opening your +channels…** instead of the blank selection view. A list with no channel to open keeps that state for up to ten +seconds, once per community visit, since the relay can still be adding a new +member to the welcome channel; a channel that arrives meanwhile opens at once. A +returning member with no channels left waits the same once. A relay whose NIP-11 +document does not advertise `buzz-landing-channel` adds no one to a welcome +channel, so its first empty list shows the empty state at once. After that +wait, an empty state offers **Try again**, which re-reads the channel list and +waits the same way again, on any relay. A welcome channel that arrives after +Messages opened without one, even after the empty state, opens then and is +saved as the selection; it replaces that opening in history, unless Settings or +another visit is shown by then. The client never joins the welcome channel for them. The relay +adds people to its welcome and `auto_join` channels itself, welcome channel +first, and the resulting membership hints populate the sidebar live: - A community that requires relay membership adds a member once, when they join its roster: an invite claim that answers `joined`, or an admin's kind `9030` @@ -1310,7 +1353,68 @@ sidebar live: Either way a later leave stands: the roster path does not run again for an existing member, and on the open path a leave or removal keeps a membership record, so the relay does not add the person back. The client only reads the -flag; it never sends a join on a member's behalf. +flag; it never sends a join on a member's behalf. The welcome channel is joined +only at a person's first entry, on either path: moving the designation never adds +anyone who already entered. + +### Pinned welcome + +The welcome is a card pinned over the end of the welcome channel's history, +directly above the composer, in the place a preview's join notice takes. It is +never a message or a row in the timeline, so it is there however long the +history is, and an empty channel shows its usual empty notice beside it. The +history's end grows by the card's height, as for the join notice, so the last +messages scroll clear of it, and a long message scrolls within the card. The +card is a region named by its title: the custom title, or the default ("Welcome +to" the community's configured name, else "Welcome"). Below it, the custom +message or the default one renders as a message body does, with its channel, +message and identity links. It never takes focus on mount; **Dismiss** removes +it and hands focus to the composer if focus was in the card. + +It shows only while all four of these hold: + +1. The open channel is the community's welcome channel (its relay-signed + `39000` carries `landing`), and the viewer is a member with its composer. +2. This device has a first-open time T for the community: unix seconds, written + the first time Messages opens the community on this device, and on an + existing install the first time after upgrading. It is view state scoped by + the community origin alone, with no viewer, under `welcome-first-open`, and + is never replaced. +3. The relay has no event by the viewer from before T less ten minutes. One + read, through the session's snapshot read (the HTTP `/query` bridge on both + hosts), asks `{ "authors": [viewer], "until": T - 600, "limit": 1 }`, with no + kinds and no `#h`, so any kind in any channel the relay lets the viewer read + counts. Replaceable events such as profiles and preferences count only if + last updated before then, since a relay keeps only their newest version. An + event means an existing member. + Because of the ten minutes, a newcomer's own events from joining, such as a + profile set up just before the app opened, do not count. The answer is kept + per device under `welcome-earlier-activity`, scoped by origin and viewer, so + another identity on the same device asks for itself. The session reader and + the browser broker admit this one filter without kinds or IDs, and only + about the viewer. +4. The card has not been dismissed on this device: `welcome-dismissed:`, scoped by origin alone. A new title or message from an admin never + brings it back. A welcome moved to another channel is a new welcome: it can + show once more there, to a member this device still classes as new, and the + channel it left stays dismissed. + +Nothing shows while the read is pending. A read that fails, whether refused, +timed out, offline or without a session, is no answer: nothing is kept and the +next visit to the welcome channel asks again, so a network failure never shows +the card to an existing member. + +These are accepted trade-offs. Someone with no events at all, such as a lurker, +looks new on each new device and sees the card once there. Dismissal is per +device, and shared by the identities on it, as T is. A lurker already in a +channel that becomes the welcome channel, such as `#general`, sees the card +once per device after the upgrade. Leaving or removing the community forgets T +and the dismissals with the rest of its device state (see +[Communities](communities.md)), so a rejoin starts over, with the read still +classing a member with older events as existing. Since T and the dismissals +are shared by the identities on the device, one identity leaving clears them +for all of them, and another identity that had dismissed the card may see it +once more. A cached roster can display previously downloaded, reverified history for up to 24 hours. It cannot authorize head/history reads, unread evidence, typing or diff --git a/docs/communities.md b/docs/communities.md index 3c5f831684..682550039c 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -65,12 +65,16 @@ an address typed on the welcome screen that the restore did not admit opens this dialog, prefilled, as the app opens. Builderlab's Settings **Create community** is the third: through the `communityJoin` capability it opens this dialog on the community just created, marked submitted, so it connects at once -as first run's **Create a community** does, without Continue. The relay adds a member newly admitted -by an invite claim to the community's starter channels, so they are already in -the sidebar and Messages opens the first of them. An open community needs no +as first run's **Create a community** does, without Continue. The relay adds a +member newly admitted by an invite claim to the community's +[welcome channel](#welcome-channel) and starter channels, so they are already +in the sidebar. Messages opens the welcome channel, or else the first starter +channel. An open community needs no claim: there the relay adds each person, though not agents, to the starter -channels they have never been in each time they connect, so those channels -appear a moment later. See [local-first launch](channels.md#local-first-launch). +channels they have never been in each time they connect, and to the welcome +channel when they first connect. Those channels appear a moment later, while +Messages shows that it is opening the person's channels. See +[local-first launch](channels.md#local-first-launch). A brand-new community has no channels. When its owner opens Messages there, having joined no channel, the client reads at most 20 stream or forum metadata @@ -140,6 +144,11 @@ sessions. Leave asks for confirmation, then sends a signed NIP-43 leave request answers end the membership here. The relay accepting, or answering that the viewer is not a member, removes the community and clears its drafts, reading positions and other device state, since the relay holds nothing to go back to. +That includes the welcome record the device keeps per community for all of its +identities (when it first opened it and which welcomes were dismissed), so a +rejoin by any identity here starts it afresh. That is an accepted trade-off: +another identity on this device that had dismissed the welcome may see it once +more. The relay answering that the viewer is banned also removes the community and disposes its session, but keeps that device state: the relay still holds the membership while a ban lasts (bans can be timed or lifted), and the data is @@ -161,6 +170,85 @@ empty state there. Try drafting in A, switching to B, then returning to A. Selected channels, drafts and reading offsets are partitioned by the canonical community origin and viewer; channel IDs alone are not sufficient keys. +## Welcome channel + +A relay that advertises the `buzz-landing-channel` NIP-11 extension keeps one +welcome channel per community. The relay adds each newly entering human to it +first, ahead of the starter channels. Messages opens it when there is no saved +selection, waiting briefly for a designated channel that is not listed yet (see +[local-first launch](channels.md#local-first-launch)). A new member sees a +welcome card pinned above its composer until they dismiss it on that device; +an existing member never does (see [pinned welcome](channels.md#pinned-welcome)). + +Owners and admins set it under **Community admin → Welcome**, which appears +when the relay-signed roster gives the viewer an owner or admin role. Each time +the card opens, it reads a fresh NIP-11 document. On a relay without the +extension it says so and shows only the starter channels. Otherwise it reads the +current designation from that document, then that channel's title and message +with a strong metadata read. If nothing is designated yet, the channel field +starts empty and must be chosen before Save. The picker lists public, unarchived +stream channels. + +The title starts with the custom title or the default: "Welcome to" and the +community's configured name, the one this viewer gave it or the relay's +published one, or just "Welcome" when the community is known only by its +address. A title is 1–120 characters on one line. The message starts with the +custom text or the default. It is edited in the message composer's editor, with +its formatting toolbar, shortcuts and link dialog; Enter adds a line break, and +there are no mentions, attachments, GIFs or cards. The message is stored as +Markdown and rendered as messages are: published Markdown opens as its source +text, as when editing a sent message, and formatting applied in the editor is +written as Markdown. `[@Name](nostr:npub…)` identity links show the person +without notifying them; a pasted npub from **Copy npub**, bare or as a +`nostr:` URI, becomes one, labelled with the person's name when it is known. +That happens only when it is pasted as a word of its own, with whitespace or +the start or end of the message on each side, outside Markdown code and links. +Anywhere else, such as after `[@Name](`, an unclosed backtick or a URL being +typed, it stays as pasted. +Channel or message links pasted from **Copy link** show as chips in the editor, +with the same link presentation as in messages, and as linked names in the +card, where they open in place for members. It takes 1–4,000 characters, with +no control characters other than line breaks and tabs. +Both are checked against the relay's rules as the admin types: text made only +of spaces and invisible characters, such as zero-width spaces or the Braille +blank, counts as empty, and hidden text-direction marks, embeddings, overrides +and isolates are refused anywhere. Line endings are +normalized and both are trimmed before sending. A message matching the default +is saved as the default rather than as custom text. The default title depends on +who is looking, so it is never inferred: an edited title is saved as written, an +untouched one stays as published, and **Restore defaults** resets both in the +draft and saves the default title. Nothing is published until **Save**. Save +sends the welcome's whole state if it changed, then each changed starter flag +in turn, and confirms each step as described in +[channel lifecycle](channels.md#channel-lifecycle); one "Welcome saved" notice +follows a complete save. A relay refusal of the welcome is shown with its reason +and the draft is kept. A step the relay has not shown yet keeps the draft with +Save disabled until **Check again** reads the relay's state for that step. If +that read finds the relay no longer supports welcome channels, the welcome draft +and its notice end with it, and the starter list stays. **Add a channel**, +**Remove** and **Cancel** announce the pending, undone or discarded change for +screen readers, and focus on a Save or Cancel that leaves rests on the welcome channel +field, or on the search where there is no welcome form. Archive, +Delete, Make private and expiry are not disabled for the welcome channel yet; the +relay refuses them, and that refusal is shown like any other lifecycle error. + +**Also add new members to** lists only the starter channels, so it stays short +however many channels the community has. The welcome channel comes first, fixed, +because new members always join it; its own flag is left unchanged. Each other +starter channel has **Remove**, and **Add a channel** searches the community's +open, unarchived stream channels that are not starters yet. Both edit the draft, +and Save and Cancel appear for starter changes alone: an added channel is listed +as not saved yet, and a removed one leaves the list until Cancel brings it back. +Save stops at a refused step. The steps before it are published, the refused +change returns to its published state with the relay's reason beside its row +for a removal or on the search for an addition, and the changes after it stay in +the draft. A confirmed change shows at once wherever the directory or roster +lists the channel, without waiting for the next directory read. Owners and +admins can add a channel they are not in. Adding a +channel applies to people who join from then on, not current members; in an +open community it also adds people who come back and have never been in that +channel. + ## Import from the previous desktop app After native startup, Buzz offers the previous desktop app's saved communities diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs index 24ec1aa51f..f24893c4a8 100644 --- a/src-tauri/src/relay/channel_writes.rs +++ b/src-tauri/src/relay/channel_writes.rs @@ -83,7 +83,79 @@ fn details_labels(labels: &[Vec]) -> bool { } } } +/// The relay's invisible characters: soft hyphen, fillers, joiners and +/// direction marks, variation selectors, Braille blank, BOM, shorthand and +/// musical format controls, tags and the like. `landing.ts` lists the same. +fn invisible(c: char) -> bool { + matches!( + c, + '\u{AD}' + | '\u{34F}' + | '\u{61C}' + | '\u{115F}' + | '\u{1160}' + | '\u{17B4}' + | '\u{17B5}' + | '\u{180B}'..='\u{180F}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{2800}' + | '\u{3164}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFA0}' + | '\u{FFF9}'..='\u{FFFB}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0000}'..='\u{E007F}' + | '\u{E0100}'..='\u{E01EF}' + ) +} +/// The relay's blank rule: every scalar value is Unicode White_Space or invisible. +fn blank(text: &str) -> bool { + text.chars().all(|c| c.is_whitespace() || invisible(c)) +} +/// Bidirectional marks, embeddings, overrides and isolates, refused anywhere. +fn bidi(c: char) -> bool { + matches!( + c, + '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} +/// The welcome message is `""` (restore the default) or 1–4000 scalar values +/// of Markdown source that is not blank, has no direction characters, and +/// whose only control characters are `\n` and `\t`, matching the relay's rules. +fn welcome(text: &str) -> bool { + text.is_empty() + || (!blank(text) + && !text.chars().any(bidi) + && text.chars().count() <= 4000 + && text + .chars() + .all(|c| matches!(c, '\n' | '\t') || !c.is_control())) +} +/// The welcome title is `""` (restore the default) or 1–120 scalar values on +/// one line: not blank, no direction characters, no control characters and no +/// Unicode line or paragraph separator, matching the relay's rules. +fn welcome_title(text: &str) -> bool { + text.is_empty() + || (!blank(text) + && !text.chars().any(bidi) + && text.chars().count() <= 120 + && !text + .chars() + .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))) +} fn lifecycle(event: &EventTemplate) -> bool { + // Owners/admins set the community's welcome channel with its full state: + // message and title, each `""` for its default. + if event.kind == 9002 && common(event) && event.tags.len() == 4 { + return tag(event, 0, "h").is_some_and(uuid) + && tag(event, 1, "landing") == Some("true") + && tag(event, 2, "welcome").is_some_and(welcome) + && tag(event, 3, "welcome_title").is_some_and(welcome_title); + } if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012) || !common(event) || event.tags.len() != if event.kind == 9002 { 2 } else { 1 } @@ -486,6 +558,174 @@ mod boundary_tests { } } #[test] + fn lifecycle_accepts_only_the_exact_landing_command() { + let channel = uuid::Uuid::nil().to_string(); + let event = serde_json::json!({ + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", channel], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", ""] + ] + }); + let longest = "é".repeat(4000); + for text in [ + "", + "Hi all!\nIntroduce yourself.", + "# Hi\n\n- one\n\t- two", + longest.as_str(), + ] { + let mut command = event.clone(); + command["tags"][2][1] = serde_json::json!(text); + assert!(validate("channel-lifecycle", &command, None).is_ok()); + assert!(validate("channel-details", &command, None).is_err()); + } + for text in [ + serde_json::json!(" \n "), + serde_json::json!("a".repeat(4001)), + serde_json::json!("line\r\nbreak"), + serde_json::json!("bell\u{7}"), + serde_json::json!("c1\u{85}"), + Value::Null, + serde_json::json!(1), + ] { + let mut command = event.clone(); + command["tags"][2][1] = text; + assert!(validate("channel-lifecycle", &command, None).is_err()); + } + // The message may span lines; the title is one line. + let longest_title = "é".repeat(120); + for title in ["", "Welcome to Acme", longest_title.as_str()] { + let mut command = event.clone(); + command["tags"][3][1] = serde_json::json!(title); + assert!(validate("channel-lifecycle", &command, None).is_ok()); + assert!(validate("channel-details", &command, None).is_err()); + } + for title in [ + serde_json::json!(" "), + serde_json::json!("a".repeat(121)), + serde_json::json!("two\nlines"), + serde_json::json!("tab\there"), + serde_json::json!("c1\u{85}"), + serde_json::json!("line\u{2028}sep"), + serde_json::json!("para\u{2029}sep"), + Value::Null, + ] { + let mut command = event.clone(); + command["tags"][3][1] = title; + assert!(validate("channel-lifecycle", &command, None).is_err()); + } + for tags in [ + serde_json::json!([["h", channel], ["landing", "true"], ["welcome", ""]]), + serde_json::json!([ + ["h", channel], + ["landing", "true"], + ["welcome_title", ""], + ["welcome", ""] + ]), + serde_json::json!([ + ["h", channel], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "", "extra"] + ]), + serde_json::json!([ + ["h", channel], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", ""], + ["welcome_title", ""] + ]), + serde_json::json!([["h", channel], ["landing", "false"], ["welcome", ""]]), + serde_json::json!([["h", channel], ["landing", "true"]]), + serde_json::json!([["h", channel], ["welcome", ""]]), + serde_json::json!([["h", channel], ["welcome", ""], ["landing", "true"]]), + serde_json::json!([["h", channel], ["landing", "true"], ["welcome"]]), + serde_json::json!([["h", channel], ["landing", "true"], ["archived", "true"]]), + serde_json::json!([ + ["h", channel], + ["landing", "true"], + ["welcome", "", "extra"] + ]), + serde_json::json!([ + ["h", channel], + ["landing", "true"], + ["welcome", ""], + ["welcome", ""] + ]), + ] { + let mut command = event.clone(); + command["tags"] = tags; + assert!(validate("channel-lifecycle", &command, None).is_err()); + } + let mut delete = event.clone(); + delete["kind"] = serde_json::json!(9008); + assert!(validate("channel-lifecycle", &delete, None).is_err()); + let mut content = event; + content["content"] = serde_json::json!("x"); + assert!(validate("channel-lifecycle", &content, None).is_err()); + } + + /// The same cases as `landing.test.ts`: text of only whitespace and + /// invisible characters is blank, and direction characters are refused + /// anywhere, for the message and the title alike. + #[test] + fn landing_command_mirrors_the_relay_blank_and_direction_rules() { + let channel = uuid::Uuid::nil().to_string(); + let event = serde_json::json!({ + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", channel], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", ""] + ] + }); + let accepted = |index: usize, text: &str| { + let mut command = event.clone(); + command["tags"][index][1] = serde_json::json!(text); + validate("channel-lifecycle", &command, None).is_ok() + }; + for text in [ + "\u{200B}", + "\u{2800}", + " \u{200B} ", + "\u{FEFF}", + "\u{AD}\u{34F}\u{115F}\u{1160}\u{3164}\u{FFA0}", + "\u{180B}\u{2060}\u{206F}\u{FE00}\u{FE0F}\u{FFF9}", + "\u{1D173}\u{E0000}\u{E007F}", + "\u{1BCA0}\u{1BCA3}\u{E0100}\u{E01EF}", + "\u{200E}\u{200F}", + "\u{2003}\u{3000}", + " \n ", + ] { + assert!(!accepted(2, text), "blank message {text:?}"); + assert!(!accepted(3, text), "blank title {text:?}"); + } + // Invisible characters beside visible text are kept. + assert!(accepted(2, "Hello\u{200B}world")); + assert!(accepted(3, "Hello\u{200B}world")); + for mark in [ + "\u{61C}", "\u{200E}", "\u{200F}", "\u{202A}", "\u{202E}", "\u{2066}", "\u{2069}", + ] { + assert!( + !accepted(2, &format!("{mark}abc")), + "direction in message {mark:?}" + ); + assert!( + !accepted(3, &format!("a{mark}bc")), + "direction in title {mark:?}" + ); + } + let overlong = format!("\u{202E}{}", "a".repeat(4001)); + assert!(!accepted(2, &overlong)); + } + #[test] fn lifecycle_accepts_only_exact_archive_and_auto_join_values() { for flag in ["archived", "auto_join"] { lifecycle_accepts_only_exact_flag_values(flag); diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 07e7d7b6d0..0538bae352 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -157,7 +157,7 @@ it.each([ ); await select(user, "General access", option); const starter = - "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first."; + "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn it off in Settings → Community admin → Welcome first."; const confirm = await screen.findByRole("alertdialog", { name: "Change general access?", }); diff --git a/src/bundled/channels/ChannelGeneralAccess.tsx b/src/bundled/channels/ChannelGeneralAccess.tsx index b44c64643c..291f51a7cd 100644 --- a/src/bundled/channels/ChannelGeneralAccess.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.tsx @@ -357,7 +357,7 @@ function ChannelGeneralAccessEditor({ : "", // The relay keeps the flag but refuses changing it on a private channel. choice.visibility === "private" && autoJoin - ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn off Starter channel for new members in Settings first." + ? "It’s a starter channel, so new members will be added to it again if it’s made public. To stop that, turn it off in Settings → Community admin → Welcome first." : "", !solo && choice.visibility === "public" ? `New people will ${choice.joinRole === "guest" ? "only be able to view" : "be able to participate"}. People already in the ${place} keep their access.` diff --git a/src/bundled/channels/ChannelLifecycleActions.test.tsx b/src/bundled/channels/ChannelLifecycleActions.test.tsx index 731c7ddab0..6f95be5ffa 100644 --- a/src/bundled/channels/ChannelLifecycleActions.test.tsx +++ b/src/bundled/channels/ChannelLifecycleActions.test.tsx @@ -28,6 +28,8 @@ function session(available = true) { subscribe: () => () => {}, observeRemoval: () => () => {}, refreshVisibility: async () => {}, + readLanding: vi.fn(), + setLanding: vi.fn(), } satisfies ChannelLifecycleCapability; return { viewer, diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx index 8d4418e6a8..813a575c95 100644 --- a/src/bundled/channels/ChannelLifecycleDialog.tsx +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -107,7 +107,7 @@ export function ChannelLifecycleDialog({ // The switch is hidden while archived, and Unarchive applies the flag again. const description = action === "archive" && autoJoin - ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.` + ? `${copy.detail} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn it off in Settings → Community admin → Welcome first.` : copy.detail; const [busy, setBusy] = useState(false); const [error, setError] = useState(""); diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index 3b3043f8a6..678b7a2c10 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -43,6 +43,8 @@ function capability() { subscribe: () => () => {}, observeRemoval: () => () => {}, refreshVisibility: async () => {}, + readLanding: vi.fn(), + setLanding: vi.fn(), } satisfies ChannelLifecycleCapability; } const viewer = "a".repeat(64), diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index a413c8cae5..cd92d688aa 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -10,16 +10,8 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createPortal } from "react-dom"; -import { afterEach, assert, expect, it, onTestFinished, vi } from "vitest"; +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; -import type { ChannelSummary } from "../../features/relay/contracts"; -import { - ChannelLifecycleUnconfirmed, - type ChannelLifecycleCapability, -} from "../../features/relay/channel-lifecycle"; -import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; -import type { Role } from "../../features/communities/roster"; -import type { CommunityMembership } from "../moderation/membership"; afterEach(() => { cleanup(); @@ -300,311 +292,3 @@ it.each(["resolve", "reject"])( ); }, ); - -function lifecycle(available = true) { - return { - available, - load: vi.fn(), - run: vi.fn(async () => {}), - snapshot: () => ({ status: "ready", hidden: [] }), - subscribe: () => () => {}, - observeRemoval: () => () => {}, - refreshVisibility: async () => {}, - } satisfies ChannelLifecycleCapability; -} -function membership(role: Role | undefined) { - const release = vi.fn(); - return { - snapshot: () => ({ - status: "ready", - members: [], - ...(role ? { role } : {}), - refreshing: false, - }), - subscribe: () => () => true, - ensure: vi.fn(() => release), - refresh: async () => {}, - dispose: () => {}, - release, - } satisfies CommunityMembership & { release: unknown }; -} -const publicStream: ChannelSummary = { - id: "11111111-1111-4111-8111-111111111111", - name: "Alpha", - channelType: "stream", - visibility: "public", - members: ["one"], -}; -function autoJoinPanel( - channel: ChannelSummary, - role: Role | undefined, - capability = lifecycle(), - roster: CommunityMembership | null = membership(role), -) { - return ( - {}} - > - Diagnostics - - ); -} -const autoJoinSwitch = () => - screen.getByRole("switch", { name: "Starter channel for new members" }); -const noAutoJoinSwitch = () => - expect( - screen.queryByRole("switch", { name: "Starter channel for new members" }), - ).not.toBeInTheDocument(); -/** Every child of the details list is a `div` of terms then definitions. */ -const expectDefinitionRows = (node: Element) => { - const list = node.closest("dl"); - assert(list); - for (const row of list.children) - expect( - `${row.tagName}:${[...row.children].map((child) => child.tagName).join(",")}`, - ).toMatch(/^DIV:DT(,DT)*(,DD)+$/); -}; - -// [case, channel, community role, switch shown, roster read held] -it.each<[string, ChannelSummary, Role | undefined, boolean, boolean]>([ - [ - "a community owner on a public stream channel", - publicStream, - "owner", - true, - true, - ], - [ - "a community admin on a public stream channel", - publicStream, - "admin", - true, - true, - ], - ["an ordinary community member", publicStream, "member", false, true], - ["an unknown community role", publicStream, undefined, false, true], - [ - "a private channel", - { ...publicStream, visibility: "private" }, - "owner", - false, - false, - ], - [ - "unknown visibility", - { ...publicStream, visibility: undefined }, - "owner", - false, - false, - ], - [ - "a forum channel", - { ...publicStream, channelType: "forum" }, - "owner", - false, - false, - ], - [ - "an archived channel", - { ...publicStream, archived: true }, - "owner", - false, - false, - ], - [ - "a read-only preview", - { ...publicStream, readOnly: true }, - "owner", - false, - false, - ], - [ - "a cached channel", - { ...publicStream, cached: true }, - "owner", - false, - false, - ], -])( - "shows the auto-join switch for %s: %s", - (_, channel, role, shown, ensured) => { - const roster = membership(role); - const error = vi.spyOn(console, "error"); - const { unmount } = render( - autoJoinPanel(channel, role, lifecycle(), roster), - ); - if (shown) { - const control = autoJoinSwitch(); - expect(control).toBeVisible(); - expect(control).toHaveAccessibleDescription( - "New community members are added to this channel automatically.", - ); - expect(control).not.toBeChecked(); - // The label is the row's term; the switch belongs to its definition. - expect(control.closest("dd")?.previousElementSibling).toHaveTextContent( - "Starter channel for new members", - ); - expectDefinitionRows(control); - expect( - screen.getByText("Description").compareDocumentPosition(control), - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); - expect(control.compareDocumentPosition(screen.getByText("Members"))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); - } else noAutoJoinSwitch(); - // The row and the channel ID detail are siblings; their keys must differ. - expect(error).not.toHaveBeenCalledWith( - expect.stringContaining("same key"), - expect.anything(), - ); - // The role read is held only while a channel-eligible row is mounted. - expect(roster.ensure).toHaveBeenCalledTimes(ensured ? 1 : 0); - unmount(); - expect(roster.release).toHaveBeenCalledTimes(ensured ? 1 : 0); - }, -); - -it("hides the switch without a membership owner and reflects the signed flag", () => { - const { rerender } = render( - autoJoinPanel(publicStream, "owner", lifecycle(), null), - ); - noAutoJoinSwitch(); - rerender(autoJoinPanel({ ...publicStream, autoJoin: true }, "owner")); - expect(autoJoinSwitch()).toBeChecked(); -}); - -it("runs the matching command, stays pending until it settles, then follows the metadata", async () => { - const user = userEvent.setup(); - const capability = lifecycle(); - let settle!: () => void; - capability.run.mockReturnValueOnce( - new Promise((resolve) => { - settle = resolve; - }), - ); - const { rerender } = render(autoJoinPanel(publicStream, "owner", capability)); - // The visible term labels the switch. - await user.click(screen.getByText("Starter channel for new members")); - try { - expect(capability.run).toHaveBeenCalledExactlyOnceWith( - "auto-join-on", - publicStream.id, - expect.any(AbortSignal), - ); - expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true"); - expect(autoJoinSwitch()).toHaveAttribute("aria-busy", "true"); - // No optimistic state: the switch follows the signed metadata. - expect(autoJoinSwitch()).not.toBeChecked(); - } finally { - await act(async () => settle()); - } - expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled"); - expect(autoJoinSwitch()).not.toHaveAttribute("aria-busy"); - rerender( - autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability), - ); - expect(autoJoinSwitch()).toBeChecked(); - await user.click(autoJoinSwitch()); - expect(capability.run).toHaveBeenLastCalledWith( - "auto-join-off", - publicStream.id, - expect.any(AbortSignal), - ); -}); - -it.each([ - [ - "the relay's own reason", - new PublishRejected("rejected", "invalid: actor not authorized"), - "invalid: actor not authorized", - ], - [ - "the default reason", - new PublishRejected("rejected"), - "Only community owners and admins can change this.", - ], - [ - "a retry for an unconfirmed outcome", - // As the lifecycle owner throws it when readback never shows the flag. - new ChannelLifecycleUnconfirmed( - new Error( - "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.", - ), - ), - "Saved, but not confirmed yet. Try again to check.", - ], - [ - "a retry for a rate limit, not a refusal", - // As the transport maps a NIP-01 `rate-limited:` OK reply. - new PublishNotSent("rate-limited: slow down"), - "Not sent. Try again.", - ], -])( - "reports %s and lets the switch be tried again", - async (_, failure, copy) => { - const user = userEvent.setup(); - const capability = lifecycle(); - capability.run.mockRejectedValueOnce(failure); - render(autoJoinPanel(publicStream, "owner", capability)); - await user.click(autoJoinSwitch()); - const alert = await screen.findByRole("alert"); - expect(alert.textContent).toBe(copy); - expectDefinitionRows(alert); - expect(autoJoinSwitch()).toHaveAccessibleDescription( - `New community members are added to this channel automatically. ${copy}`, - ); - expect(autoJoinSwitch()).not.toHaveAttribute("aria-disabled"); - expect(autoJoinSwitch()).not.toBeChecked(); - await user.click(autoJoinSwitch()); - await waitFor(() => - expect(screen.queryByRole("alert")).not.toBeInTheDocument(), - ); - expect(autoJoinSwitch()).toHaveAccessibleDescription( - "New community members are added to this channel automatically.", - ); - expect(capability.run).toHaveBeenCalledTimes(2); - }, -); - -it("drops an unconfirmed prompt once the signed flag shows the change landed", async () => { - const user = userEvent.setup(); - const capability = lifecycle(); - capability.run.mockRejectedValueOnce( - new ChannelLifecycleUnconfirmed(new Error("not confirmed")), - ); - const { rerender } = render(autoJoinPanel(publicStream, "owner", capability)); - await user.click(autoJoinSwitch()); - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent( - "Saved, but not confirmed yet. Try again to check.", - ); - expect(autoJoinSwitch().getAttribute("aria-describedby")).toContain(alert.id); - // A refresh that still lacks the flag keeps the prompt. - rerender(autoJoinPanel({ ...publicStream }, "owner", capability)); - expect(screen.getByRole("alert")).toBe(alert); - // A later 39000 shows the flag on; "Try again" would now turn it off. - rerender( - autoJoinPanel({ ...publicStream, autoJoin: true }, "owner", capability), - ); - expect(autoJoinSwitch()).toBeChecked(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(autoJoinSwitch().getAttribute("aria-describedby")).not.toContain( - alert.id, - ); - expect(autoJoinSwitch()).toHaveAccessibleDescription( - "New community members are added to this channel automatically.", - ); - // Another admin turning it off later does not bring the stale prompt back. - rerender(autoJoinPanel(publicStream, "owner", capability)); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(capability.run).toHaveBeenCalledOnce(); -}); - -it("disables the switch when channel actions are unavailable on this connection", () => { - render(autoJoinPanel(publicStream, "owner", lifecycle(false))); - expect(autoJoinSwitch()).toHaveAttribute("aria-disabled", "true"); -}); diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 709200f12a..0139c54bbc 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -4,19 +4,11 @@ import { useId, useRef, useState, - useSyncExternalStore, type ReactElement, type ComponentProps, type ReactNode, } from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; -import { - ChannelLifecycleUnconfirmed, - type ChannelLifecycleCapability, -} from "../../features/relay/channel-lifecycle"; -import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; -import { managesCommunity } from "../../features/communities/roster"; -import type { CommunityMembership } from "../moderation/membership"; import type { RelaySession } from "../../features/relay/session"; import { MessageMarkdown } from "../../features/messages/MessageMarkdown"; import { useReferenceDirectory } from "../../features/messages/ReferenceText"; @@ -32,7 +24,6 @@ import { Tabs } from "../../shared/design-system/ui/Tabs"; import { Tooltip } from "../../shared/design-system/ui/Tooltip"; import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; -import { Switch } from "../../shared/design-system/ui/Switch"; import styles from "./Channels.module.css"; import type { ChannelDetailsCapability } from "../../features/relay/channel-details"; import { @@ -48,8 +39,6 @@ export function ChannelSettingsPanel({ children, setupTools, details, - lifecycle, - membership, openMembers, session, canOpenLink, @@ -61,9 +50,6 @@ export function ChannelSettingsPanel({ children: ReactNode; setupTools?: ReactNode; details?: ChannelDetailsCapability; - /** With `membership`, offers the community owner/admin auto-join switch. */ - lifecycle?: ChannelLifecycleCapability | undefined; - membership?: CommunityMembership | undefined; openMembers?(trigger: HTMLButtonElement): void; /** Links the description like a message; without these it stays plain text. */ session?: RelaySession | undefined; @@ -217,18 +203,6 @@ export function ChannelSettingsPanel({ } /> )} - {editable && - channel.channelType === "stream" && - channel.visibility === "public" && - lifecycle && - membership && ( - - )} {channel.members && ( membership.snapshot().role, - ); - useEffect(() => membership.ensure(), [membership]); - const [pending, setPending] = useState(false); - const [error, setError] = useState<{ enabled: boolean; message: string }>(); - // Once the signed flag shows what a failed toggle wanted, the change is in - // effect, and "Try again" would flip it back. - if (error && !!channel.autoJoin === error.enabled) setError(undefined); - const operation = useRef(undefined); - useEffect(() => () => operation.current?.abort(), []); - const control = useId(); - const description = useId(); - const alert = useId(); - if (!managesCommunity(role)) return null; - const disabled = pending || !lifecycle.available; - const toggle = async (enabled: boolean) => { - if (operation.current) return; - const controller = new AbortController(); - operation.current = controller; - setPending(true); - setError(undefined); - try { - await lifecycle.run( - enabled ? "auto-join-on" : "auto-join-off", - channel.id, - controller.signal, - ); - } catch (error) { - if (!controller.signal.aborted) - setError({ - enabled, - message: - // Setting the same value again is idempotent, so retrying is the check. - error instanceof ChannelLifecycleUnconfirmed - ? "Saved, but not confirmed yet. Try again to check." - : // A rate limit or a write that never left this device, not a refusal. - error instanceof PublishNotSent - ? "Not sent. Try again." - : error instanceof PublishRejected - ? (error.reason ?? - "Only community owners and admins can change this.") - : error instanceof Error - ? error.message - : String(error), - }); - } finally { - operation.current = undefined; - if (!controller.signal.aborted) setPending(false); - } - }; - // A row of the details list: the label is its term, and the description, - // switch and any error are its definition. - return ( -
-
- -
-
- - New community members are added to this channel automatically. - - void toggle(enabled)} - /> - {error && ( - - )} -
-
- ); -} - /** Only a linked description reads the reference directory, so its snapshots * re-render this value rather than the whole panel. */ function ChannelDescription({ diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index d1732fe7eb..1d334423ca 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -822,19 +822,6 @@ details:not([open]) > summary .sectionChevron { margin: var(--space-1) 0 0; color: var(--text-subtle); } -/* The switch trails its description; an error spans the row below both. */ -.settingsSwitch > dd { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-1) var(--space-3); -} -.settingsSwitch > dd > [role="alert"] { - grid-column: 1 / -1; -} -.settingsSwitch[data-disabled] > dt { - color: var(--text-unavailable); -} .settingsId { overflow-wrap: anywhere; } diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 619dc8dd4d..4d38f410b1 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -21,28 +21,31 @@ import type { } from "../../features/panels/service"; import type { PageNavigation } from "../../features/navigation/service"; import type { OpenTarget } from "../../features/navigation/targets"; -import { soloDraftTarget } from "../../features/channel-navigation/routes"; +import { + conversationTarget, + soloDraftTarget, +} from "../../features/channel-navigation/routes"; import { createRelaySession, type RelaySession, } from "../../features/relay/session"; import type { RelayData } from "../../features/relay/service"; import { matchesEvent } from "../../features/relay/projection"; -import type { RelayEvent } from "../../features/relay/events"; +import type { ReadFilter, RelayEvent } from "../../features/relay/events"; import { + bounds, keypair, metadata, roster, signed, } from "../../features/relay/testing"; -import { writeView } from "../../shared/view-state"; +import { readView, writeView } from "../../shared/view-state"; import type { Navigation } from "../../features/navigation/controller"; import { useChannelTabState } from "./useChannelTabState"; import { setThreadPlacementPreference } from "../../features/messages/thread-preferences"; import { SOLO_LABEL } from "../../features/solo/labels"; import { composerDOMFixture } from "../../features/messages/composer-testing"; -import { ChannelsPage } from "./ChannelsPage"; -import type { CommunityMembership } from "../moderation/membership"; +import { ChannelsPage, type LandingReader } from "./ChannelsPage"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { BrowserHostView } from "../../features/browser/BrowserHostView"; import { BrowserSessions } from "../../features/browser/sessions"; @@ -302,38 +305,39 @@ it.each(["side", "main"] as const)( }, ); -/** A page over `events` with no saved selection and no registered panels. */ +/** A page over `events` with no saved selection and no registered panels. + * A read waits for the promise `hold` returns for its filters, if any; the + * relay's welcome-channel support is read from `landing`, if given. A history + * window is served only for a channel whose 39006 bounds are in `events`. */ function workspace( events: RelayEvent[], viewer = keypair(), authority = keypair(), - { - membership, - lifecycle, - }: { - membership?: CommunityMembership; - lifecycle?: (event: RelayEvent) => Promise; - } = {}, + hold?: (filters: readonly ReadFilter[]) => Promise | undefined, + landing?: LandingReader, ) { const owner = createRelaySession( { viewer: viewer.pubkey, relayAuthor: authority.pubkey, media: () => undefined, - query: async (filters) => - events.filter((event) => - filters.some((filter) => matchesEvent(event, filter)), - ), + query: async (filters) => { + await hold?.(filters); + return events.filter((event) => + filters.some( + (filter) => + matchesEvent(event, filter) || + // The relay answers a window read with its bounds. + (event.kind === 39006 && + !!filter.top_level && + matchesEvent(event, { "#h": filter["#h"] ?? [], limit: 1 })), + ), + ); + }, writer: { sign: async (template) => signed(viewer, template), publish: async () => {}, }, - ...(lifecycle && { - channelLifecycle: { - sign: async (template) => signed(viewer, template), - publish: lifecycle, - }, - }), }, { outboxStorage: { load: () => [], save: () => {} } }, ); @@ -361,9 +365,8 @@ function workspace( requestOpen: () => false, handleOpenRequests: () => () => {}, }; - const navigator = createNavigationController( - createMemoryHistory(), - ).navigation; + const controller = createNavigationController(createMemoryHistory()); + const navigator = controller.navigation; const page = (navigation?: PageNavigation) => ( ); const { rerender } = render(page()); - /** Re-route the mounted page; `undefined` returns to the unrouted workspace. */ - const visit = (target?: OpenTarget) => + /** Re-route the mounted page; `undefined` returns to the unrouted workspace. + * A `hosted` visit answers the navigator's pending attempt, as the host's + * does, so once it has opened it resolves nothing. */ + const visit = (target?: OpenTarget, entryId = "visit", hosted = false) => rerender( page( target && { - entryId: "visit", + entryId, target, signal: new AbortController().signal, position: () => undefined, @@ -390,12 +395,24 @@ function workspace( forSession() { return this; }, - resolve: () => true, - complete: () => true, + resolve: hosted + ? (next) => controller.resolve(navigator.snapshot().attempt, next) + : () => true, + complete: hosted + ? (result) => + controller.complete(navigator.snapshot().attempt, result) + : () => true, }, ), ); - return { owner, viewer, authority, scope: snapshot.scope, visit }; + return { + owner, + viewer, + authority, + scope: snapshot.scope, + navigator, + visit, + }; } it("opens the community's auto-join channel first when nothing is saved", async () => { @@ -417,52 +434,544 @@ it("opens the community's auto-join channel first when nothing is saved", async await screen.findByRole("textbox", { name: "Message #Beta" }); }); -it("hands the community roster to Channel settings and follows the republished flag", async () => { +it("opens the community's welcome channel before its auto-join channel and pins the welcome above the composer", async () => { const viewer = keypair(), authority = keypair(); - const id = "11111111-1111-4111-8111-111111111111"; - const open = (at: number, extra: string[][] = []) => - metadata(authority, id, "Alpha", at, [ - ["t", "stream"], - ["public"], - ...extra, - ]); - const events = [roster(authority, id, [viewer.pubkey]), open(1_700_000_000)]; - const ensure = vi.fn(() => () => {}); - workspace(events, viewer, authority, { - membership: { - snapshot: () => ({ - status: "ready", - members: [], - role: "owner", - refreshing: false, + workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ["welcome", "Say **hi**\nand introduce yourself."], + ["welcome_title", "Hello, newcomer"], + ]), + // Beta's history is empty. + bounds(authority, "beta", "head", { has_more: false, next_cursor: null }), + ], + viewer, + authority, + ); + const composer = await screen.findByRole("textbox", { + name: "Message #Beta", + }); + // This viewer has written nothing on the relay, so they are new here. + const welcome = await screen.findByRole("region", { + name: "Hello, newcomer", + }); + // The message is Markdown, rendered as a message body is: its single + // newline is a line break. + expect(welcome).toHaveTextContent("Say hiand introduce yourself."); + expect(welcome.querySelector("strong")).toHaveTextContent("hi"); + expect(welcome.querySelector("strong + br")).not.toBeNull(); + // Pinned beside the history, not a row in it; an empty channel says so. + expect(welcome.closest("[data-message-scroller]")).toBeNull(); + expect(await screen.findByText("No messages yet.")).toBeVisible(); + // It never takes focus, and the history leaves room for it. + expect(welcome).not.toContainElement(document.activeElement as HTMLElement); + const conversation = composer.closest( + "[data-attachment-drop-zone]", + ); + expect( + conversation?.style.getPropertyValue("--channel-preview-inset"), + ).toMatch(/^calc\(/); + await userEvent.click( + within(welcome).getByRole("button", { name: "Dismiss" }), + ); + expect(screen.queryByRole("region", { name: "Hello, newcomer" })).toBeNull(); + expect(conversation?.style.getPropertyValue("--channel-preview-inset")).toBe( + "0px", + ); + // Dismiss leaves with the card, so focus goes on to the composer. + await vi.waitFor(() => expect(composer).toHaveFocus()); +}); + +it("pins no welcome for a member who wrote on the relay before this device first opened it", async () => { + const viewer = keypair(), + authority = keypair(); + const reads: (readonly ReadFilter[])[] = []; + workspace( + [ + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + signed(viewer, { + kind: 9, + content: "Hi all", + created_at: Math.floor(Date.now() / 1000) - 86_400, + tags: [["h", "beta"]], }), - subscribe: () => () => true, - ensure, - refresh: async () => {}, - dispose: () => {}, + ], + viewer, + authority, + (filters) => { + reads.push(filters); + return undefined; }, - // The relay applies the accepted 9002 and republishes its metadata. - lifecycle: async (event) => { - if (event.kind === 9002) - events.push(open(1_700_000_100, [["auto_join"]])); + ); + await screen.findByRole("textbox", { name: "Message #Beta" }); + // One read over every kind and channel, for this viewer's events from + // before the first open less ten minutes. + await vi.waitFor(() => + expect( + readView( + `https://relay.test:${viewer.pubkey}`, + "welcome-earlier-activity", + undefined, + ), + ).toBe(true), + ); + const asked = reads.filter((filters) => + filters.some((filter) => !filter.kinds && !filter.ids), + ); + expect(asked).toHaveLength(1); + expect(asked[0]).toStrictEqual([ + { + authors: [viewer.pubkey], + limit: 1, + until: + readView("https://relay.test", "welcome-first-open", 0) - 600, }, + ]); + expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull(); +}); + +it("waits for the welcome channel's metadata before opening an auto-join channel listed first", async () => { + const viewer = keypair(), + authority = keypair(); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; }); + let holding = false; + const { owner } = workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ], + viewer, + authority, + (filters) => { + // Hold every metadata read naming the welcome channel. + if ( + !filters.some( + (filter) => + filter.kinds?.includes(39000) && filter["#d"]?.includes("beta"), + ) + ) + return; + holding = true; + return held; + }, + ); + try { + await vi.waitFor(() => expect(holding).toBe(true)); + // The auto-join channel's metadata lands first, by an exact lookup. + await act(() => owner.session.channels.resolve?.(["alpha"])); + const listed = owner.session.channels.list().channels; + const alpha = listed.find(({ id }) => id === "alpha"); + expect(alpha?.autoJoin).toBe(true); + expect(alpha?.metadataPending).toBeFalsy(); + expect(listed.find(({ id }) => id === "beta")?.metadataPending).toBe(true); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + expect( + screen.queryByRole("textbox", { name: "Message #Alpha" }), + ).toBeNull(); + } finally { + release(); + } + await screen.findByRole("textbox", { name: "Message #Beta" }); + expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull(); +}); + +it("shows the default welcome only in the welcome channel", async () => { + const viewer = keypair(), + authority = keypair(); + const { visit, scope } = workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [ + ["t", "stream"], + // A stale message without the designation is ignored. + ["welcome", "Old"], + ]), + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ], + viewer, + authority, + ); + expect( + await screen.findByRole("region", { name: "Welcome" }), + ).toHaveTextContent("This is a place to connect with the community"); + visit(conversationTarget(scope, "alpha")); await screen.findByRole("textbox", { name: "Message #Alpha" }); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: "Channel actions" })); - await user.click( - await screen.findByRole("menuitem", { name: "View channel details" }), + expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull(); + expect(screen.queryByText("Old")).toBeNull(); +}); + +it("keeps a saved selection over the welcome channel", async () => { + const viewer = keypair(), + authority = keypair(); + writeView(`https://relay.test:${viewer.pubkey}`, "selected-channel", "alpha"); + workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ], + viewer, + authority, + ); + await screen.findByRole("textbox", { name: "Message #Alpha" }); + expect(screen.queryByRole("region", { name: "Welcome" })).toBeNull(); +}); + +it("falls back when the viewer is not in the welcome channel", async () => { + const viewer = keypair(), + authority = keypair(), + other = keypair(); + workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + roster(authority, "beta", [other.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["public"], + ["landing"], + ]), + ], + viewer, + authority, + ); + await screen.findByRole("textbox", { name: "Message #Alpha" }); +}); + +it("keeps opening after an empty first list, then offers a list refresh instead of a blank view", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const viewer = keypair(), + authority = keypair(); + const events: RelayEvent[] = []; + const { owner } = workspace(events, viewer, authority); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + // The relay may still be adding this new member to the welcome channel. + expect(screen.getByText("Opening your channels…")).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "You’re not in any channels yet" }), + ).toBeNull(); + await act(() => vi.advanceTimersByTimeAsync(9_000)); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect( + screen.getByRole("heading", { name: "You’re not in any channels yet" }), + ).toBeVisible(); + expect(screen.queryByText("Select a channel to read it.")).toBeNull(); + // Try again re-reads the list rather than reconnecting a healthy session, + // and waits for it the same bounded way. + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ); + const tryAgain = screen.getByRole("button", { name: "Try again" }); + tryAgain.focus(); + fireEvent.click(tryAgain); + expect( + screen.queryByRole("heading", { name: "You’re not in any channels yet" }), + ).toBeNull(); + expect( + screen.getByRole("region", { name: "Channel history" }), + ).toHaveFocus(); + await screen.findByRole("textbox", { name: "Message #Beta" }); + } finally { + vi.useRealTimers(); + } +}); + +it("opens the welcome channel as soon as it arrives while the opening state waits", async () => { + const viewer = keypair(), + authority = keypair(); + const events: RelayEvent[] = []; + const { owner } = workspace(events, viewer, authority); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ); + owner.session.channels.refreshList?.(); + await screen.findByRole("textbox", { name: "Message #Beta" }); + expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "You’re not in any channels yet" }), + ).toBeNull(); +}); + +/** The Messages page, opened through the navigator's own visit. */ +const messagesPage: OpenTarget = { + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", +}; + +it("shows an empty list at once on a relay without welcome channels", async () => { + const landing = vi.fn(async () => ({ + landingChannels: false, + })); + const { owner } = workspace([], keypair(), keypair(), undefined, landing); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + // No one is being added to a welcome channel, so there is nothing to wait for. + expect( + await screen.findByRole("heading", { + name: "You’re not in any channels yet", + }), + ).toBeVisible(); + expect(screen.queryByText("Opening your channels…")).toBeNull(); + expect(landing).toHaveBeenCalledWith("https://relay.test", expect.anything()); +}); + +/** A member listed in a DM, and with `joined` in a stream channel they joined + * themselves, on a relay whose NIP-11 names `beta` as its welcome channel. */ +async function awaitingWelcome({ hosted = false, joined = false } = {}) { + const viewer = keypair(), + authority = keypair(), + other = keypair(); + const events: RelayEvent[] = [ + roster(authority, "pat", [viewer.pubkey, other.pubkey]), + metadata(authority, "pat", "Pat", undefined, [["t", "dm"]]), + ...(joined + ? [ + roster(authority, "alpha", [viewer.pubkey, other.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + ] + : []), + ]; + const landing = vi.fn(async () => ({ + landingChannels: true, + landingChannel: "beta", + })); + const { owner, scope, navigator, visit } = workspace( + events, + viewer, + authority, + undefined, + landing, + ); + if (hosted) visit(messagesPage, navigator.snapshot().entry.id, true); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + await vi.waitFor(() => expect(landing).toHaveBeenCalledOnce()); + // The read has answered and the page has rendered its outcome. + await act(async () => {}); + return { owner, scope, navigator, viewer, authority, events }; +} + +it("waits for the welcome channel the relay names rather than open a DM, then opens it", async () => { + const { owner, viewer, authority, events } = await awaitingWelcome(); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + expect(screen.queryByRole("textbox")).toBeNull(); + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), ); - const control = await screen.findByRole("switch", { - name: "Starter channel for new members", + owner.session.channels.refreshList?.(); + await screen.findByRole("textbox", { name: "Message #Beta" }); + expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible(); +}); + +it("opens a channel the person joined themselves at once, though the relay names an unlisted welcome channel", async () => { + // Only a new member's list, of starter channels and DMs, is still being + // filled by the relay; an established member's is not held. + await awaitingWelcome({ joined: true }); + expect( + await screen.findByRole("textbox", { name: "Message #Alpha" }), + ).toBeVisible(); + expect(screen.queryByText("Opening your channels…")).toBeNull(); +}); + +it("skips the NIP-11 read for a saved selection whose metadata is still arriving", async () => { + const viewer = keypair(), + authority = keypair(); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; }); - expect(ensure).toHaveBeenCalledOnce(); - expect(control).not.toBeChecked(); - await user.click(control); - await vi.waitFor(() => expect(control).toBeChecked()); - expect(control).not.toHaveAttribute("aria-busy"); - expect(control.closest("dd")?.querySelector("[role=alert]")).toBeNull(); + const landing = vi.fn(async () => ({ + landingChannels: true, + landingChannel: "beta", + })); + const scope = `https://relay.test:${viewer.pubkey}`; + writeView(scope, "selected-channel", "alpha"); + const { owner } = workspace( + [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [["t", "stream"]]), + ], + viewer, + authority, + (filters) => + filters.some( + (filter) => + filter.kinds?.includes(39000) && filter["#d"]?.includes("alpha"), + ) + ? held + : undefined, + landing, + ); + try { + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + expect( + owner.session.channels.list().channels.find(({ id }) => id === "alpha") + ?.metadataPending, + ).toBe(true); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + } finally { + release(); + } + await screen.findByRole("textbox", { name: "Message #Alpha" }); + expect(landing).not.toHaveBeenCalled(); +}); + +it("reads NIP-11 and holds for a newcomer's list whose saved selection is archived", async () => { + const viewer = keypair(), + authority = keypair(), + other = keypair(); + const landing = vi.fn(async () => ({ + landingChannels: true, + landingChannel: "beta", + })); + const scope = `https://relay.test:${viewer.pubkey}`; + // An archived selection is listed, but not shown or read; the rest of the + // list is a DM, as a new member's can be. + writeView(scope, "selected-channel", "old"); + const { owner } = workspace( + [ + roster(authority, "old", [viewer.pubkey]), + metadata(authority, "old", "Old", undefined, [ + ["t", "stream"], + ["archived", "true"], + ]), + roster(authority, "pat", [viewer.pubkey, other.pubkey]), + metadata(authority, "pat", "Pat", undefined, [["t", "dm"]]), + ], + viewer, + authority, + undefined, + landing, + ); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + await vi.waitFor(() => expect(landing).toHaveBeenCalledOnce()); + await act(async () => {}); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + expect(screen.queryByRole("textbox")).toBeNull(); +}); + +it("opens and keeps the DM once the welcome channel the relay names stays unlisted", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { scope, navigator } = await awaitingWelcome({ hosted: true }); + // The visit opened on the wait; the DM then replaces it. + expect(navigator.snapshot().status).toBe("opened"); + await act(() => vi.advanceTimersByTimeAsync(9_000)); + expect(screen.getByText("Opening your channels…")).toBeVisible(); + expect(screen.queryByRole("textbox")).toBeNull(); + await act(() => vi.advanceTimersByTimeAsync(1_000)); + expect(await screen.findByRole("textbox")).toBeVisible(); + expect(navigator.snapshot()).toMatchObject({ + canGoBack: false, + entry: { target: { kind: "conversation", channelId: "pat" } }, + }); + // Saved, so the next opening shows it without waiting again. + expect(readView(scope, "selected-channel", undefined)).toBe("pat"); + } finally { + vi.useRealTimers(); + } +}); + +it("opens and saves a welcome channel that arrives after the empty list settled", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const viewer = keypair(), + authority = keypair(); + const events: RelayEvent[] = []; + const { owner, scope, navigator, visit } = workspace( + events, + viewer, + authority, + ); + visit(messagesPage, navigator.snapshot().entry.id, true); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect( + screen.getByRole("heading", { name: "You’re not in any channels yet" }), + ).toBeVisible(); + // The visit opened on the empty state, so it can no longer be resolved. + expect(navigator.snapshot().status).toBe("opened"); + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ); + owner.session.channels.refreshList?.(); + await screen.findByRole("textbox", { name: "Message #Beta" }); + // The same opening, corrected: no way back to the empty state. + expect(navigator.snapshot()).toMatchObject({ + canGoBack: false, + entry: { target: { kind: "conversation", channelId: "beta" } }, + }); + expect(readView(scope, "selected-channel", undefined)).toBe("beta"); + } finally { + vi.useRealTimers(); + } }); it("selects a default channel the relay adds after the page opened", async () => { @@ -484,6 +993,172 @@ it("selects a default channel the relay adds after the page opened", async () => await screen.findByRole("textbox", { name: "Message #Beta" }); }); +/** A new member's empty first list, then a membership hint confirming their + * starter channel on its own read, as the relay's hints can arrive; with + * `listedFirst`, the first list already has it. Routed, the page shows the + * navigator's current visit, as the host routes Messages. */ +async function starterConfirmedFirst({ + routed = false, + listedFirst = false, +} = {}) { + const viewer = keypair(), + authority = keypair(); + const starter = [ + roster(authority, "alpha", [viewer.pubkey]), + metadata(authority, "alpha", "Alpha", undefined, [ + ["t", "stream"], + ["auto_join"], + ]), + ]; + const events: RelayEvent[] = listedFirst ? [...starter] : []; + const { owner, scope, navigator, visit } = workspace( + events, + viewer, + authority, + ); + if (routed) + visit( + { + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }, + navigator.snapshot().entry.id, + ); + await vi.waitFor(() => + expect(owner.session.channels.list().status).toBe("ready"), + ); + if (!listedFirst) { + events.push(...starter); + await act(() => owner.session.channels.resolve?.(["alpha"])); + } + const alpha = await screen.findByRole("textbox", { name: "Message #Alpha" }); + /** The welcome channel's hint, confirmed on a later read. Returns once the + * page has rendered the confirmed list and run its effects, so the switch + * has happened or never will; the first test below proves it. */ + const confirmWelcome = async () => { + events.push( + roster(authority, "beta", [viewer.pubkey]), + metadata(authority, "beta", "Beta", undefined, [ + ["t", "stream"], + ["landing"], + ]), + ); + await act(() => owner.session.channels.resolve?.(["beta"])); + await vi.waitFor(() => + expect( + owner.session.channels.list().channels.find(({ id }) => id === "beta") + ?.landing, + ).toBe(true), + ); + await act(async () => {}); + }; + return { alpha, scope, navigator, visit, confirmWelcome }; +} + +/** The starter channel kept: shown, and its history entry untouched. */ +function expectStarterKept(navigator: Navigation, shown = "home") { + expect(navigator.snapshot().entry.target.kind).toBe(shown); + expect(screen.getByRole("textbox", { name: "Message #Alpha" })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Message #Beta" })).toBeNull(); +} + +it("opens a welcome channel confirmed after the starter channel it opened first", async () => { + const { navigator, confirmWelcome } = await starterConfirmedFirst(); + await confirmWelcome(); + // Asserted at once: the barrier the tests below rely on covers the switch. + // It replaces the opening, leaving no way back to the starter channel. + expect(navigator.snapshot()).toMatchObject({ + canGoBack: false, + entry: { target: { kind: "conversation", channelId: "beta" } }, + }); + await screen.findByRole("textbox", { name: "Message #Beta" }); + expect(await screen.findByRole("region", { name: "Welcome" })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Message #Alpha" })).toBeNull(); +}); + +it("opens a welcome channel that follows the starter channel across a routed visit's handoff", async () => { + // The starter channel is chosen while the routed visit is still pending. + const { scope, navigator, visit, confirmWelcome } = + await starterConfirmedFirst({ routed: true, listedFirst: true }); + const entryId = navigator.snapshot().entry.id; + // Resolving the visit to it hands the route off: one commit without + // navigation, then the same history entry as a conversation. + visit(); + visit(conversationTarget(scope, "alpha"), entryId); + await confirmWelcome(); + expect(navigator.snapshot()).toMatchObject({ + canGoBack: false, + entry: { target: { kind: "conversation", channelId: "beta" } }, + }); +}); + +it.each([ + [ + "clicks in it", + async (alpha: HTMLElement) => { + await userEvent.setup().click(alpha); + }, + ], + [ + "presses a key outside Messages", + async () => { + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "Sidebar" }), { + key: "Tab", + }); + }, + ], +])( + "keeps the starter channel once the reader %s, though the welcome channel follows", + async (_, input) => { + const { alpha, navigator, confirmWelcome } = await starterConfirmedFirst(); + await input(alpha); + await confirmWelcome(); + expectStarterKept(navigator); + }, +); + +it.each([ + ["a routed", true], + ["an unrouted", false], +])( + "leaves Settings open over %s starter channel when the welcome channel follows", + async (_, routed) => { + const { navigator, confirmWelcome } = await starterConfirmedFirst({ + routed, + }); + // Messages stays mounted with its visit behind Settings, as the host keeps it. + void navigator.open({ version: 1, kind: "settings" }); + await confirmWelcome(); + expectStarterKept(navigator, "settings"); + }, +); + +it("leaves focus outside Messages where it is when the welcome channel replaces the starter channel", async () => { + const { confirmWelcome } = await starterConfirmedFirst(); + render(); + const sidebar = screen.getByRole("button", { name: "Sidebar" }); + // Focus can move without a click or key press, as when a dialog returns it. + act(() => sidebar.focus()); + await confirmWelcome(); + await screen.findByRole("textbox", { name: "Message #Beta" }); + expect(sidebar).toHaveFocus(); +}); + +it("keeps the starter channel when the welcome channel follows after the opening window", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { navigator, confirmWelcome } = await starterConfirmedFirst(); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + await confirmWelcome(); + expectStarterKept(navigator); + } finally { + vi.useRealTimers(); + } +}); + it("keeps the channel being read when a default channel arrives later", async () => { const viewer = keypair(), authority = keypair(); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 2057bdb217..008546ddf7 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -54,7 +54,11 @@ import { ChannelJoinNotice } from "../../features/messages/ChannelJoinNotice"; import { ChannelLifecycleActions } from "./ChannelLifecycleActions"; import { lifecycleNoun } from "./ChannelLifecycleDialog"; import type { PageNavigation } from "../../features/navigation/service"; -import type { Navigation } from "../../features/navigation/controller"; +import type { + Navigation, + NavigationFocus, + NavigationOpenOptions, +} from "../../features/navigation/controller"; import { targetKey, type OpenTarget } from "../../features/navigation/targets"; import { buzzLinkTarget, @@ -86,6 +90,7 @@ import { LockIcon, ChatCircleIcon, GearIcon, + HashIcon, } from "../../shared/design-system/icons/index"; import { channelIcon } from "../../features/channels/channel-icon"; import { communityRefusalTitle } from "../../features/federated-identity/CommunitySignIn"; @@ -126,6 +131,16 @@ import { channelHasUnread } from "./useSidebarFilters"; import { inFieldOutsideComposer } from "./conversation-rows"; import styles from "./Channels.module.css"; import type { CommunityReader } from "../../features/communities/service"; +import { + PinnedWelcome, + WelcomeMessage, +} from "../../features/communities/WelcomeCard"; +import { + recordWelcomeFirstOpen, + usePinnedWelcome, +} from "../../features/communities/welcome-pin"; +import type { CommunityInfo } from "../../features/communities/api"; +import { communityFromScope } from "../../features/relay/gifs"; import { avatarMedia } from "../../shared/avatar-source"; /** The open Solo conversation's Archive, published for the shortcut while its visit is live. */ @@ -148,6 +163,7 @@ export function ChannelsPage({ navigator, agents, communities, + landing, archive, read, membership, @@ -157,8 +173,11 @@ export function ChannelsPage({ /** For adding a whole team of the owner's agents from the members dialog. */ agents?: Agents | undefined; communities?: CommunityReader | undefined; - /** The viewer's community role, for the auto-join switch in Channel settings. */ + /** The viewer's community role, for creating #general in an empty community. */ membership?: CommunityMembership | undefined; + /** Reads the relay's welcome-channel support, so a new member's opening can + * wait for the channel the relay is adding them to. */ + landing?: LandingReader | undefined; relay: RelayData; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -321,6 +340,7 @@ export function ChannelsPage({ agents={agents} communities={communities} membership={membership} + landing={landing} /> )} @@ -335,6 +355,29 @@ function LiveNewMessage( return ; } +/** How long a channel list with nothing to open keeps the opening state. */ +const ENROLLMENT_WAIT_MS = 10_000; + +type Landing = Pick; +export type LandingReader = ( + community: string, + signal: AbortSignal, +) => Promise; + +/** Focus for a default that replaces its visit's opening: focus the reader + * has moved out of Messages stays where it is. */ +function correctionFocus( + history: HTMLElement | null, + focus: NavigationFocus | undefined, +): NavigationFocus | undefined { + const active = document.activeElement; + return active && + active !== document.body && + !history?.closest("[data-channel-workspace]")?.contains(active) + ? "preserve" + : focus; +} + function ChannelWorkspace({ extensions, chatMenus, @@ -349,6 +392,7 @@ function ChannelWorkspace({ viewer, agents, communities, + landing, archive, read, membership, @@ -359,6 +403,7 @@ function ChannelWorkspace({ membership?: CommunityMembership | undefined; companion?: ReactNode; communities?: CommunityReader | undefined; + landing?: LandingReader | undefined; scope: string; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -399,7 +444,10 @@ function ChannelWorkspace({ // Start the optional catalog afterward so opening Messages cannot strand it. if (list.status === "ready") queries.channelKit.ensure(); }, [queries, list.status]); + // The join notice's height, or the pinned welcome's: never both at once. const [joinNoticeHeight, setJoinNoticeHeight] = useState(0); + // Before any passive effect, so the pinned welcome's first read has it. + useLayoutEffect(() => recordWelcomeFirstOpen(scope), [scope]); const [contentReadyChannel, setContentReadyChannel] = useState(); const [composerFocus, setComposerFocus] = useState(0); const [trackSoloMove, soloMoveNotices] = useSoloMoveRecovery(queries); @@ -433,19 +481,22 @@ function ChannelWorkspace({ return () => clearPreparingDm?.(); }, [composingMessage, clearPreparingDm]); const navigate = useCallback( - (id: string) => { + (id: string, options?: NavigationOpenOptions) => { setSelected(id); writeView(scope, "selected-channel", id); if (navigator && viewer) { - void navigator.open({ - version: 1, - kind: "conversation", - channelId: id, - scope: { - viewer, - communityOrigin: scope.slice(0, -(viewer.length + 1)), + void navigator.open( + { + version: 1, + kind: "conversation", + channelId: id, + scope: { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }, }, - }); + options, + ); } }, [navigator, viewer, scope], @@ -558,6 +609,94 @@ function ChannelWorkspace({ !!draft || (navigation?.target.kind === "page" && navigation.target.route?.params === "empty"); + const kept = + (!navigation && !cached && visiblePreview.current?.queries === queries + ? queries.channels.get?.(visiblePreview.current.channelId) + : undefined) ?? + channels.find((channel) => channel.id === selected) ?? + // A computed choice stays put while its channel is listed, so a channel + // flagged or added later never swaps the one being read; only a welcome + // channel can replace a provisional fallback (see `provisional`). + channels.find((channel) => channel.id === chosen.current); + // A new member lands in the community's welcome channel, else in its + // default channel when it has one. + const welcomeChannel = channels.find( + (channel) => channel.landing && channel.channelType === "stream", + ); + /** When the list first loaded; the opening waits are measured from it. */ + const listedAt = useRef(undefined); + // With nothing saved or requested, the relay's NIP-11 document names its + // welcome channel before the list can: a new member's list may not have it + // yet. A saved selection whose metadata is still arriving needs no read; one + // the list shows is `kept`, and an archived one is no longer being read. + const community = communityFromScope(scope); + const needsLanding = + !!landing && + !!community && + !cached && + !emptyDestination && + !requestedChannel && + !kept && + (!selected || + (list.status === "ready" && + !list.channels.some( + (channel) => channel.id === selected && channel.metadataPending, + ))); + const [landingRead, setLandingRead] = useState(); + useEffect(() => { + if (!needsLanding || landingRead || !landing || !community) return; + const reading = new AbortController(); + landing(community, reading.signal).then( + (info) => !reading.signal.aborted && setLandingRead(info), + () => !reading.signal.aborted && setLandingRead("failed"), + ); + return () => reading.abort(); + }, [needsLanding, landingRead, landing, community]); + const landingInfo = landingRead === "failed" ? undefined : landingRead; + const awaited = landingInfo?.landingChannel; + // While that read is pending, or names a channel not listed yet, no other + // channel opens in its place, until ENROLLMENT_WAIT_MS after the list loaded. + // Only while the list still looks like a new member's: nothing but the + // relay's starter channels and DMs. A channel the person joined themselves + // means the relay is not adding them to the welcome channel now, as for a + // member from before it was designated, or one who left it. + const [heldOut, setHeldOut] = useState(false); + const newcomer = channels.every( + (channel) => + channel.autoJoin || + channel.channelType === "dm" || + channel.channelType === "solo", + ); + const holdFallback = + needsLanding && + !heldOut && + !welcomeChannel && + newcomer && + list.status !== "error" && + (!landingRead || + (!!awaited && !list.channels.some((channel) => channel.id === awaited))); + useEffect(() => { + if (!holdFallback || list.status !== "ready") return; + const timer = setTimeout( + () => setHeldOut(true), + Math.max( + 0, + (listedAt.current ?? Date.now()) + ENROLLMENT_WAIT_MS - Date.now(), + ), + ); + return () => clearTimeout(timer); + }, [holdFallback, list.status]); + // A channel whose metadata is still pending can be the welcome channel, so + // the starter and first-channel fallbacks wait for it rather than settle first. + const fallback = + kept || + welcomeChannel || + holdFallback || + (metadataPending && list.status !== "error") + ? undefined + : (channels.find( + (channel) => channel.autoJoin && channel.channelType !== "solo", + ) ?? channels.find((item) => item.channelType !== "solo")); const requestedDestination = emptyDestination ? undefined : requestedChannel @@ -567,18 +706,7 @@ function ChannelWorkspace({ (previewAvailable ? queries.channels.get?.(requestedChannel) : undefined)) - : ((!navigation && !cached && visiblePreview.current?.queries === queries - ? queries.channels.get?.(visiblePreview.current.channelId) - : undefined) ?? - channels.find((channel) => channel.id === selected) ?? - // A computed choice stays put while its channel is listed, so a channel - // flagged or added later never swaps the one being read. - channels.find((channel) => channel.id === chosen.current) ?? - // A new member lands in the community's default channel when it has one. - channels.find( - (channel) => channel.autoJoin && channel.channelType !== "solo", - ) ?? - channels.find((item) => item.channelType !== "solo")); + : (kept ?? welcomeChannel ?? fallback); // Exact links and saved selections must respect the same Huddle-only entry point. const resolving = resolvingMembership || @@ -587,14 +715,116 @@ function ChannelWorkspace({ requestedDestination?.huddle || requestedDestination?.metadataPending ? undefined : requestedDestination; + // With nothing requested, the default channel is still arriving rather than + // unchosen: a new member's welcome channel can follow the first list. + const defaulting = !emptyDestination && !requestedChannel && !current; + const emptyList = + defaulting && + list.status === "ready" && + !metadataPending && + !channels.some((channel) => channel.channelType !== "solo"); + // A list with nothing to open can be a new member's, read before the relay + // has added them to the welcome channel. Any such empty default keeps + // opening for a bounded time, once per workspace, before offering recovery; + // a relay without welcome channels adds no one, so its first list does not + // wait. Try again refreshes the list and waits once more. + const [listAttempt, setListAttempt] = useState(0); + const [waitedOut, setWaitedOut] = useState(-1); + const unenrolled = + waitedOut === listAttempt || + (listAttempt === 0 && landingInfo?.landingChannels === false); + const noChannels = emptyList && unenrolled; + useEffect(() => { + if (!emptyList || unenrolled) return; + const timer = setTimeout( + () => setWaitedOut(listAttempt), + ENROLLMENT_WAIT_MS, + ); + return () => clearTimeout(timer); + }, [emptyList, listAttempt, unenrolled]); + const opening = + defaulting && + (list.status === "idle" || + list.status === "loading" || + metadataPending || + holdFallback || + (emptyList && !noChannels)); + // The relay's membership hints can confirm a new member's starter channel + // before their welcome channel, so a fallback chosen with no welcome channel + // listed is provisional. Until the reader clicks or types anywhere or moves + // to another visit, a welcome channel arriving within ENROLLMENT_WAIT_MS of + // the list first loading replaces it. + const provisional = useRef<{ + channelId: string; + entryId: string | undefined; + /** Removes the input listeners that settle it. */ + listening: AbortController; + }>(undefined); + const settleDefault = useCallback(() => { + provisional.current?.listening.abort(); + provisional.current = undefined; + }, []); + useEffect(() => settleDefault, [settleDefault]); useLayoutEffect(() => { + if (list.status === "ready") listedAt.current ??= Date.now(); + // The route handoff briefly passes no navigation; only another visit + // settles the choice. + if ( + navigation && + provisional.current && + provisional.current.entryId !== navigation.entryId + ) + settleDefault(); // An open draft resolves nothing; keep the choice for when it closes. - if (requestedDestination) chosen.current = requestedDestination.id; + if (requestedDestination) { + if (!requestedChannel && requestedDestination === fallback) { + settleDefault(); + // A click or key press anywhere settles it, such as tabbing through + // the sidebar or typing in a field outside Messages. + const listening = new AbortController(); + for (const type of ["pointerdown", "keydown"]) + document.addEventListener(type, settleDefault, { + capture: true, + signal: listening.signal, + }); + provisional.current = { + channelId: requestedDestination.id, + entryId: navigation?.entryId, + listening, + }; + } + chosen.current = requestedDestination.id; + } visiblePreview.current = current?.readOnly && !current.cached && !cached ? { channelId: current.id, queries } : undefined; }); + const welcomeId = welcomeChannel?.id; + const currentId = current?.id; + const visitFocus = navigation?.focus; + useEffect(() => { + const choice = provisional.current; + if (!choice || !welcomeId) return; + settleDefault(); + const shown = navigator?.snapshot().entry; + if ( + currentId !== choice.channelId || + welcomeId === choice.channelId || + listedAt.current === undefined || + Date.now() - listedAt.current >= ENROLLMENT_WAIT_MS || + // Only the provisional visit while it is shown: never Settings over it, + // nor a visit opened since. An unrouted workspace has no visit to match, + // so only Settings is checked. + (choice.entryId === undefined + ? shown?.target.kind === "settings" + : shown?.id !== choice.entryId) + ) + return; + const focus = correctionFocus(historyContainer.current, visitFocus); + // The same opening, corrected: no return path to the fallback. + navigate(welcomeId, { replace: true, ...(focus && { focus }) }); + }, [welcomeId, currentId, visitFocus, navigate, navigator, settleDefault]); // Sidebar routing can update the same mounted page. Keep its saved default // aligned with the resolved conversation, not only page-local clicks. useEffect(() => { @@ -613,6 +843,8 @@ function ChannelWorkspace({ } }, [joiningChannel, current]); const CurrentChannelIcon = channelIcon(current); + /** The opened visit a late default last replaced, which it does once. */ + const corrected = useRef(undefined); useEffect(() => { if (navigation?.signal.aborted) return; if (composingMessage || draft) { @@ -641,7 +873,7 @@ function ChannelWorkspace({ navigation?.complete({ status: "opened" }); if (!requestedChannel && current && navigation && viewer) { // Resolve the saved default within this attempt, keeping its caller and deadline. - navigation.resolve({ + const resolved = navigation.resolve({ version: 1, kind: "conversation", channelId: current.id, @@ -650,6 +882,25 @@ function ChannelWorkspace({ communityOrigin: scope.slice(0, -(viewer.length + 1)), }, }); + // The visit opened with nothing to show, as the opening waits do. The + // welcome channel arriving since, or the fallback once a held opening + // gave up on it, replaces that visit so its route and selection follow. + // Only while the visit is shown, never Settings or a visit opened since. + const shown = navigator.snapshot(); + if ( + !resolved && + (current.id === welcomeId || heldOut) && + shown.status === "opened" && + shown.entry.id === navigation.entryId && + corrected.current !== navigation.entryId + ) { + corrected.current = navigation.entryId; + const focus = correctionFocus( + historyContainer.current, + navigation.focus, + ); + navigate(current.id, { replace: true, ...(focus && { focus }) }); + } } }, [ cached, @@ -662,8 +913,12 @@ function ChannelWorkspace({ current, list, navigation, + navigator, + navigate, viewer, scope, + welcomeId, + heldOut, ]); const requestedMessage = navigation?.target.kind === "conversation" @@ -696,7 +951,6 @@ function ChannelWorkspace({ navigation.resolve(target); } }, [cached, current, navigation]); - const currentId = current?.id; useEffect(() => { setMembersChannel((id) => (id === currentId ? id : undefined)); }, [currentId]); @@ -1957,8 +2211,6 @@ function ChannelWorkspace({ } channel={current} details={queries.channelDetails} - lifecycle={queries.channelLifecycle} - membership={membership} session={queries} canOpenLink={canOpenLink} onOpenLink={openLink} @@ -2025,6 +2277,18 @@ function ChannelWorkspace({ (current.archived || (current.readOnly && (!current.cached || current.viewerRole === "guest"))); + const composes = + !!current && + !current.archived && + current.viewerRole !== "guest" && + (!current.readOnly || !!current.cached); + const welcome = usePinnedWelcome({ + session: queries, + scope, + channelId: current?.id, + // A member of the welcome channel, with its composer. + member: !!current?.landing && current.channelType === "stream" && composes, + }); useChannelHistoryFocus( historyContainer, navigation, @@ -2045,7 +2309,7 @@ function ChannelWorkspace({ } tabIndex={-1} ref={historyContainer} - className={`relative flex min-h-0 flex-col${current ? " flex-1" : ""}`} + className={`relative flex min-h-0 flex-col${current || noChannels ? " flex-1" : ""}`} > {current ? ( + ) : noChannels ? ( +
+ } + title="You’re not in any channels yet" + description="Channels you join will appear here. If you just joined this community, try again." + action={ + + } + /> +
) : (
{resolving ? "Checking conversation access…" - : "Select a channel to read it."} + : opening + ? "Opening your channels…" + : "Select a channel to read it."}
)} {current && channelNotice && ( @@ -2097,6 +2387,30 @@ function ChannelWorkspace({ onHeightChange={setJoinNoticeHeight} /> )} + {current && welcome.shown && ( + { + welcome.dismiss(); + // Dismiss leaves with the card; typing is what comes next. + if (focused) setComposerFocus((value) => value + 1); + }} + onHeightChange={setJoinNoticeHeight} + > + + + )} {!cached && ( )} - {current && - !current.archived && - current.viewerRole !== "guest" && - (!current.readOnly || current.cached) && ( - - ) : undefined - } - onSend={onComposerSend} - onCommand={handleCommand} - focusRequest={composerFocus} - /> - )} + {current && composes && ( + + ) : undefined + } + onSend={onComposerSend} + onCommand={handleCommand} + focusRequest={composerFocus} + /> + )} ); const workspace = ( @@ -2207,9 +2518,10 @@ function ChannelWorkspace({ className={`${styles.conversation}${solo ? ` ${styles.soloConversation}` : ""}`} style={ { - "--channel-preview-inset": channelNotice - ? `calc(${joinNoticeHeight}px + var(--space-3))` - : "0px", + "--channel-preview-inset": + channelNotice || welcome.shown + ? `calc(${joinNoticeHeight}px + var(--space-3))` + : "0px", } as React.CSSProperties } data-attachment-drop-zone="" diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 423c2796b3..09073c4e06 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -9,12 +9,23 @@ import { registerCanvas } from "./canvas-panel"; import { ChannelTemplateSettings } from "./ChannelTemplateSettings"; import { ChatCircleIcon, CopyIcon } from "../../shared/design-system/icons"; import { isApplePlatform } from "../../features/shortcuts/format"; -import type { ArchiveShortcut, ReadShortcut } from "./ChannelsPage"; +import type { + ArchiveShortcut, + LandingReader, + ReadShortcut, +} from "./ChannelsPage"; +import { + communityRequest, + type CommunityInfo, +} from "../../features/communities/api"; import { conversationRows, inTerminal, stepConversation, } from "./conversation-rows"; +/** The relay's welcome-channel support and designation, read live from NIP-11. */ +const readLanding: LandingReader = (community, signal) => + communityRequest(community, "info", undefined, signal); export const inject = [ "pages", "relay", @@ -148,6 +159,7 @@ export const apply: PluginModule["apply"] = (ctx) => { agents={agents} communities={ctx.communityReader} membership={membership} + landing={readLanding} /> ), }); diff --git a/src/bundled/moderation/CommunityAdmin.native.test.tsx b/src/bundled/moderation/CommunityAdmin.native.test.tsx index b9297362e7..53115aad19 100644 --- a/src/bundled/moderation/CommunityAdmin.native.test.tsx +++ b/src/bundled/moderation/CommunityAdmin.native.test.tsx @@ -190,7 +190,7 @@ afterEach(() => { vi.mocked(invoke).mockClear(); }); -it("keeps the Membership card registered in a native build", () => { +it("keeps the Membership and Welcome cards registered in a native build", () => { expect(nativeIdentityEnabled()).toBe(true); const settingsCards = { register: vi.fn() }; const data = relay(owner).relay; @@ -201,7 +201,8 @@ it("keeps the Membership card registered in a native build", () => { settingsCards, effect: vi.fn(), } as unknown as Parameters[0]); - expect(settingsCards.register).toHaveBeenCalledExactlyOnceWith( + expect(settingsCards.register).toHaveBeenCalledTimes(2); + expect(settingsCards.register).toHaveBeenCalledWith( expect.objectContaining({ id: "membership", title: "Membership", @@ -209,6 +210,14 @@ it("keeps the Membership card registered in a native build", () => { component: expect.any(Function), }), ); + expect(settingsCards.register).toHaveBeenCalledWith( + expect.objectContaining({ + id: "welcome", + title: "Welcome", + section: "administration", + component: expect.any(Function), + }), + ); membership.dispose(); }); diff --git a/src/bundled/moderation/README.md b/src/bundled/moderation/README.md index 1ab91e432c..83022d720a 100644 --- a/src/bundled/moderation/README.md +++ b/src/bundled/moderation/README.md @@ -15,3 +15,22 @@ community where you are an owner or admin. Direct addition by public identity is retained behind `DIRECT_MEMBER_ADD_ENABLED` for a possible return, but is hidden in the shipped UI. + +On relays that support it, **Settings → Community admin → Welcome** lets an +owner or admin choose the public channel new members join and open first, and +the title and message of the welcome card pinned above that channel's +composer for new members. The message editor has the composer's formatting +toolbar and saves Markdown; pasted channel and +message links show with the same link presentation as in messages, through +the conversation's link renderers. Saving moves the welcome to the chosen +channel; people already in the community keep their channels. Editing the +title or message never shows the card again to anyone who dismissed it; +moving the welcome to another channel is a new welcome, which can show once +more there. + +Below that, **Also add new members to** lists the starter channels, which new +members join too: **Add a channel** searches other open channels, and **Remove** +takes one off. These edit the same draft as the welcome: **Save** publishes +the welcome first, then each starter change, and **Cancel** discards them. +Saved changes apply to people who join afterwards. The list, with its Save, +also appears on relays without a welcome channel. diff --git a/src/bundled/moderation/WelcomeMessageEditor.module.css b/src/bundled/moderation/WelcomeMessageEditor.module.css new file mode 100644 index 0000000000..ca51e8153d --- /dev/null +++ b/src/bundled/moderation/WelcomeMessageEditor.module.css @@ -0,0 +1,19 @@ +/* The editor and its toolbar inside the field's shared frame, which owns the + surface and the focus stroke. */ +.frame { + display: grid; + flex: 1; + align-self: stretch; + gap: var(--space-2); + min-width: 0; + padding-block: var(--space-control-inset) var(--space-1); + color: var(--text-standard); +} +/* About six lines, as the plain field had, growing with the message. */ +.frame > [role="textbox"] { + min-height: 6lh; + max-height: 24rem; +} +.frame [data-placeholder]::before { + color: var(--text-metadata); +} diff --git a/src/bundled/moderation/WelcomeMessageEditor.tsx b/src/bundled/moderation/WelcomeMessageEditor.tsx new file mode 100644 index 0000000000..feeb69fc9e --- /dev/null +++ b/src/bundled/moderation/WelcomeMessageEditor.tsx @@ -0,0 +1,277 @@ +import { + memo, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { WelcomeMessage } from "../../features/communities/WelcomeCard"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import { ComposerFormattingTools } from "../../features/messages/ComposerFormattingTools"; +import { ComposerLinkDialog } from "../../features/messages/ComposerLinkDialog"; +import { + composerMarkdownContext, + markdownRanges, + readComposerSnapshot, + type SourceRange, +} from "../../features/messages/composer-document"; +import type { + ComposerFormat, + ComposerInputElement, + ComposerLinkEdit, +} from "../../features/messages/composer-dom"; +import { composerMarkdown } from "../../features/messages/composer-markdown"; +import { + EditableInput, + type EditorDecoration, +} from "../../features/messages/EditableInput"; +import { + mentionDraft, + type MentionDraft, +} from "../../features/messages/mention-draft"; +import { profileMentionLink } from "../../features/messages/message-edit"; +import { messageLinkParts } from "../../features/messages/message-link-parts"; +import { profileKey } from "../../features/profiles/target"; +import { WELCOME_MAX_LENGTH } from "../../features/relay/landing"; +import { scanMarkdown } from "../../features/relay/message-content"; +import type { RelaySession } from "../../features/relay/session"; +import { formatPublicKey } from "../../shared/identity/public-key"; +import { Field } from "../../shared/design-system/ui/Field"; +import { InputGroup } from "../../shared/design-system/ui/InputGroup"; +import styles from "./WelcomeMessageEditor.module.css"; + +const noOpen = () => false; +/** Chip artwork whose props hold across keystrokes, so typing prose does not + * re-render every link in the message. */ +const LinkToken = memo(WelcomeMessage); + +/** + * Identity, channel, message and web links outside code: the spans the editor + * shows as chips, as the composer does. This is deliberately narrower than + * `RichComposerInput`'s `decorationsFor`: plain `#name` and `@Name` + * references and `:shortcode:` emoji are not chips. The card renders + * through `MessageMarkdown`, so it may show a reference that stays plain + * text here. + */ +function linkRanges(text: string, rich: readonly SourceRange[]) { + const blocked = [...rich, ...markdownRanges(text).literal]; + const ranges: SourceRange[] = []; + const add = (start: number, end: number) => { + if ( + ![...blocked, ...ranges].some( + (range) => start < range.end && end > range.start, + ) + ) + ranges.push({ start, end }); + }; + for (const link of scanMarkdown(text).links) { + const start = link.position?.start.offset; + const end = link.position?.end.offset; + if ( + link.url && + profileKey(link.url) && + start !== undefined && + end !== undefined + ) + add(start, end); + } + messageLinkParts(text, undefined, add); + return ranges.sort((a, b) => a.start - b.start); +} + +/** Where a paste lands: over `start`–`end` of the draft text `source`. */ +type PasteAt = SourceRange & { source: string }; + +/** Whether `text`, pasted as is, stands alone in prose: at the start of the + * draft or after whitespace, at its end or before whitespace, and not inside + * Markdown code or a link. A paste joined to other text, such as an unclosed + * code span, a bare URL or a link destination being typed, does not. */ +function pastedAlone(text: string, { source, start, end }: PasteAt) { + const before = source.slice(0, start); + const rest = source.slice(end); + if (!/(^|\s)$/.test(before) || !/^(\s|$)/.test(rest)) return false; + const after = before + text + rest; + const pasted = { start, end: start + text.length }; + const spans = [ + ...markdownRanges(after).literal, + ...scanMarkdown(after).links.map(({ position }) => ({ + start: position?.start.offset ?? 0, + end: position?.end.offset ?? 0, + })), + ]; + return !spans.some( + (span) => pasted.start > span.start && pasted.end < span.end, + ); +} + +/** + * A pasted npub, bare as Copy npub writes it or as a `nostr:` URI, becomes an + * identity link labelled with the person's name when it is known, so it shows + * as a chip. Like every identity link here, it notifies no one. Pasted into + * Markdown code or a link, or joined to other text, it stays as pasted. + */ +function identityPaste( + text: string, + at: PasteAt, + names: RelaySession["names"], +) { + const npub = text.trim().replace(/^nostr:/, ""); + const target = `nostr:${npub}`; + const pubkey = profileKey(target); + if (!pubkey || !pastedAlone(text, at)) return text; + const name = names?.resolve(pubkey) ?? formatPublicKey(pubkey) ?? npub; + return profileMentionLink(`@${name}`, target); +} + +/** + * The welcome message in the composer's editor, with its formatting toolbar, + * shortcuts and link dialog. The value is Markdown source, as the relay stores + * it: formatting applied here is written as the composer sends it, and + * published Markdown opens as its source and is sent back unchanged. There + * are no recipients, so identity links, pasted npubs included, only name + * people. + */ +export function WelcomeMessageEditor({ + ref, + label, + description, + error, + value, + onChange, + disabled, + session, + scope, + channelId, + extensions, +}: { + ref: RefObject; + label: string; + description: ReactNode; + error?: string | undefined; + /** Markdown source. */ + value: string; + onChange(value: string): void; + disabled: boolean; + session: RelaySession; + scope: string; + /** Scopes the names identity chips show to this channel's members. */ + channelId: string; + /** The link renderers messages use, so chips here look as the card's links do. */ + extensions: ConversationExtensions | undefined; +}) { + const id = useId(); + const [draft, setDraft] = useState(() => mentionDraft(value)); + // The Markdown the editor shows, to tell its own changes from new values. + const shown = useRef(value); + const [formats, setFormats] = useState([]); + const [linkEdit, setLinkEdit] = useState(null); + const [pasteTooLong, setPasteTooLong] = useState(false); + const links = useRef<{ text: string; ranges: readonly SourceRange[] }>( + undefined, + ); + // A value from elsewhere (the published text, Cancel, Restore defaults) + // replaces the document; undo starts again from it. + useLayoutEffect(() => { + if (value === shown.current) return; + const next = mentionDraft(value); + shown.current = value; + ref.current?.reset(next); + setDraft(next); + setPasteTooLong(false); + }, [value, ref]); + const decorationsFor = (next: MentionDraft): EditorDecoration[] => { + const doc = readComposerSnapshot(next.document); + const context = doc + ? composerMarkdownContext(doc) + : { text: next.text, protected: [] }; + if (links.current?.text !== context.text) + links.current = { + text: context.text, + ranges: linkRanges(context.text, context.protected), + }; + return links.current.ranges.map(({ start, end }) => ({ + start, + end, + editAsText: true, + content: ( + + ), + })); + }; + const problem = + error ?? + (pasteTooLong + ? "This paste would make the message too long. Shorten the text and try again." + : undefined); + return ( + <> + {label}} + nativeLabel={false} + description={{description}} + error={problem && {problem}} + > + +
+ { + setDraft(next); + setPasteTooLong(false); + const markdown = composerMarkdown(next); + if (markdown === shown.current) return; + shown.current = markdown; + onChange(markdown); + }} + onFormatsChange={setFormats} + onEditLink={setLinkEdit} + onPasteTooLong={() => setPasteTooLong(true)} + pastedText={(text, at) => identityPaste(text, at, session.names)} + /> + ref.current?.toggleFormat(format)} + editLink={() => { + const edit = ref.current?.editLink(); + if (edit) setLinkEdit(edit); + }} + > + {null} + +
+
+
+ {linkEdit && ( + setLinkEdit(null)} + /> + )} + + ); +} diff --git a/src/bundled/moderation/WelcomeSettings.module.css b/src/bundled/moderation/WelcomeSettings.module.css new file mode 100644 index 0000000000..6844b32952 --- /dev/null +++ b/src/bundled/moderation/WelcomeSettings.module.css @@ -0,0 +1,22 @@ +.editor { + display: grid; + gap: var(--space-6); + min-width: 0; +} +.editor[data-placeholder] { + visibility: hidden; +} +.fields { + display: grid; + gap: var(--space-4); + min-width: 0; +} +.section { + display: grid; + gap: var(--space-3); + min-width: 0; +} +.unconfirmed { + display: grid; + gap: var(--space-2); +} diff --git a/src/bundled/moderation/WelcomeSettings.test.tsx b/src/bundled/moderation/WelcomeSettings.test.tsx new file mode 100644 index 0000000000..8c3225216b --- /dev/null +++ b/src/bundled/moderation/WelcomeSettings.test.tsx @@ -0,0 +1,1479 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { npubEncode } from "nostr-tools/nip19"; +import { afterEach, expect, it, vi } from "vitest"; +import { + communityRequest, + type CommunityInfo, +} from "../../features/communities/api"; +import type { + CommunityReader, + Membership, +} from "../../features/communities/service"; +import { DEFAULT_WELCOME_MESSAGE } from "../../features/communities/WelcomeCard"; +import type { + ConversationExtensions, + LinkRenderer, +} from "../../features/conversation/contracts"; +import type { ComposerInputElement } from "../../features/messages/composer-dom"; +import { composerDOMFixture } from "../../features/messages/composer-testing"; +import { profileTarget } from "../../features/profiles/target"; +import type { Contribution } from "../../plugins/contributions"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLanding, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { PublishRejected } from "../../features/relay/outbox"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { WelcomeSettings } from "./WelcomeSettings"; + +vi.mock("../../features/communities/api", async (importOriginal) => ({ + ...(await importOriginal()), + communityRequest: vi.fn(), +})); + +composerDOMFixture(); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const owner = "0".repeat(64); +const lobby = "11111111-1111-4111-8111-111111111111"; +const square = "22222222-2222-4222-8222-222222222222"; +const random = "55555555-5555-4555-8555-555555555555"; +type DirectoryRow = Readonly<{ + id: string; + name: string; + channelType: "stream" | "forum"; + autoJoin?: true; + archived?: true; +}>; +const rows: DirectoryRow[] = [ + { id: lobby, name: "Lobby", channelType: "stream" as const }, + { + id: square, + name: "Town square", + channelType: "stream" as const, + autoJoin: true as const, + }, + { + id: random, + name: "Random", + channelType: "stream" as const, + }, + { + id: "33333333-3333-4333-8333-333333333333", + name: "Old news", + channelType: "stream" as const, + archived: true as const, + }, + { + id: "44444444-4444-4444-8444-444444444444", + name: "Questions", + channelType: "forum" as const, + }, +]; + +function setup({ + info = { policy: null, landingChannels: true, landingChannel: lobby }, + landing = { landing: true, welcome: "Hi there", name: "Lobby" }, + joined = [], + directory = rows, + names, + membership = { name: "Primary" }, + extensions, +}: { + info?: CommunityInfo; + landing?: ChannelLanding | undefined; + /** The viewer's own channels, beside the directory. */ + joined?: ChannelSummary[]; + directory?: DirectoryRow[]; + /** Known names by public key. */ + names?: Readonly>; + /** How the viewer's saved membership names the community. */ + membership?: Omit; + /** The conversation's renderers, as the plugin passes them. */ + extensions?: ConversationExtensions; +} = {}) { + const readLanding = vi.fn( + async () => landing, + ); + const setLanding = vi.fn( + async () => {}, + ); + let list = { status: "ready", channels: joined }; + let listed = directory; + const rosterListeners = new Set<() => void>(); + const directoryListeners = new Set<() => void>(); + /** The relay shows a channel's starter flag, as a strong read hands it to + * the roster and the directory. */ + const flag = (id: string, enabled: boolean) => { + const set = (row: T): T => { + if (row.id !== id) return row; + const { autoJoin: _, ...rest } = row; + return (enabled ? { ...rest, autoJoin: true } : rest) as T; + }; + listed = listed.map(set); + list = { ...list, channels: list.channels.map(set) }; + act(() => { + for (const listener of [...rosterListeners, ...directoryListeners]) + listener(); + }); + }; + // A confirmed command's readback updates the flag before it resolves. + const run = vi.fn(async (action, id) => + flag(id, action === "auto-join-on"), + ); + const session = { + viewer: owner, + media: () => undefined, + channelLifecycle: { available: true, readLanding, setLanding, run }, + ...(names && { + names: { + resolve: (pubkey: string, fallback?: string) => + names[pubkey] ?? fallback, + snapshot: () => 0, + subscribe: () => () => {}, + }, + }), + channels: { + list: () => list, + ensureList: () => () => {}, + subscribeList(listener: () => void) { + rosterListeners.add(listener); + return () => rosterListeners.delete(listener); + }, + subscribeDirectory(listener: () => void) { + directoryListeners.add(listener); + return () => directoryListeners.delete(listener); + }, + openDirectory: () => listed, + browseDirectory: () => listed, + readDirectory: async () => ({ partial: false }), + directoryComplete: () => true, + }, + } as unknown as RelaySession; + let snapshot = { + status: "ready", + generation: 1, + scope: `https://primary.example:${owner}`, + viewer: owner, + session, + }; + const listeners = new Set<() => void>(); + const relay = { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as RelayData; + /** The relay republished a joined channel's metadata. */ + const setJoined = (channels: ChannelSummary[]) => { + list = { status: "ready", channels }; + act(() => { + for (const listener of rosterListeners) listener(); + }); + }; + /** The same community reconnects: a new session generation. */ + const reconnect = () => { + snapshot = { ...snapshot, generation: snapshot.generation + 1 }; + act(() => { + for (const listener of listeners) listener(); + }); + }; + // A stable snapshot, as the real reader keeps: a new one each call would + // never let useSyncExternalStore settle. + const saved = { + memberships: [{ id: "https://primary.example", ...membership }], + }; + const communities = { + snapshot: () => saved, + subscribe: () => () => {}, + } as unknown as CommunityReader; + vi.mocked(communityRequest).mockResolvedValue(info); + const view = render( + true} + extensions={extensions} + />, + { wrapper: ToastProvider }, + ); + return { + readLanding, + setLanding, + run, + flag, + setJoined, + reconnect, + view, + user: userEvent.setup(), + }; +} + +const channel = () => screen.getByRole("combobox", { name: "Welcome channel" }); +const titleField = () => screen.getByRole("textbox", { name: "Title" }); +const messageField = () => screen.getByRole("textbox", { name: "Message" }); +/** Replace the whole message, as the published text arriving would. The + * editor's value setter dispatches its change at once, so it runs in act: + * fireEvent.change would set it while building the event, outside act. */ +const setMessage = (text: string) => + act(() => { + (messageField() as ComposerInputElement).value = text; + }); +const starters = () => + screen.getByRole("region", { name: "Also add new members to" }); +const removeLabel = (name: string) => `Remove #${name} from starter channels`; +const remove = (name: string) => + screen.getByRole("button", { name: removeLabel(name) }); +const removable = (name: string) => + screen.queryByRole("button", { name: removeLabel(name) }); +const addField = () => screen.getByRole("combobox", { name: "Add a channel" }); +const welcome = (message: string, title = "") => ({ message, title }); + +it("opens on the designated channel, its title and its message, read fresh", async () => { + const { readLanding } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + // The chosen channel shows its icon, as in the list. + expect( + channel() + .closest(".buzz-input-group") + ?.querySelector(".buzz-input-leading svg"), + ).not.toBeNull(); + expect(titleField()).toHaveValue("Welcome to Primary"); + expect(messageField()).toHaveValue("Hi there"); + expect(communityRequest).toHaveBeenCalledWith( + "https://primary.example", + "info", + undefined, + expect.any(AbortSignal), + ); + expect(readLanding).toHaveBeenCalledWith(lobby, expect.any(AbortSignal)); + // Nothing changed yet, so there is nothing to save. + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); +}); + +it("opens on a custom title", async () => { + setup({ + landing: { + landing: true, + welcome: "Say **hi**", + welcomeTitle: "Hello, newcomer", + name: "Lobby", + }, + }); + await waitFor(() => expect(titleField()).toHaveValue("Hello, newcomer")); + expect(messageField()).toHaveValue("Say **hi**"); + expect( + screen.getByText( + /Format with the toolbar or Markdown\. To link a channel or person, paste a copied channel link or npub\./, + ), + ).toBeVisible(); +}); + +it("writes toolbar formatting and a pasted channel link into the message as Markdown", async () => { + const { setLanding, user } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + const input = messageField(); + setMessage(""); + // A channel's Copy link writes the bare link. + const link = `buzz://channel/${square}`; + act(() => { + input.focus(); + document.dispatchEvent(new Event("selectionchange")); + fireEvent.paste(input, { + clipboardData: { + items: [], + getData: (type: string) => (type === "text/plain" ? link : ""), + }, + }); + }); + // A pointer click leaves focus and the caret in the editor. + await user.click(screen.getByRole("button", { name: "Toggle formatting" })); + await user.click(screen.getByRole("button", { name: "Bold" })); + expect(input).toHaveFocus(); + await user.keyboard("Join"); + expect(input.querySelector("strong")).toHaveTextContent("Join"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`${link} **Join**`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +// Link styling comes from the links plugin's renderer, which messages reach +// through the conversation extensions. Without them, a pasted channel link +// shows as plain `#name` text in the editor while the card underlines it. +it("shows a pasted channel link through the conversation's link renderer, as the card does", async () => { + const renderer: Contribution = { + id: "links", + title: "Links", + key: "test/links", + pluginId: "test", + revision: "one", + matches: (url) => url.startsWith("buzz://channel/"), + className: "link-style", + component: ({ url }) => {url}, + }; + const subscribe = () => () => {}; + // Registries keep their snapshot until a contribution changes. + const links = [renderer]; + const none: never[] = []; + setup({ + extensions: { + links: { snapshot: () => links, subscribe }, + inline: { snapshot: () => none, subscribe }, + tools: { snapshot: () => none, subscribe }, + }, + }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + pasteIntoMessage(`buzz://channel/${square}`); + const chip = await waitFor(() => { + const found = messageField().querySelector("[data-link-renderer]"); + expect(found).not.toBeNull(); + return found as HTMLElement; + }); + // The chip only names the channel: no anchor, so nothing opens from the editor. + expect(chip.tagName).toBe("SPAN"); + expect(chip).toHaveAttribute("data-link-renderer", "test/links"); + expect(chip).toHaveClass("link-style"); + expect(chip).toHaveTextContent(`buzz://channel/${square}`); +}); + +/** A plain-text clipboard holding `text`. */ +const plainClipboard = (text: string) => ({ + clipboardData: { + items: [], + getData: (type: string) => (type === "text/plain" ? text : ""), + }, +}); + +/** Paste `text` as plain text into a message field holding only `before` and + * `after`, with the caret between them. */ +function pasteIntoMessage(text: string, before = "", after = "") { + setMessage(before + after); + const input = messageField() as ComposerInputElement; + act(() => { + input.focus(); + input.setSelectionRange(before.length, before.length); + document.dispatchEvent(new Event("selectionchange")); + fireEvent.paste(input, plainClipboard(text)); + }); +} + +it("links a pasted npub to the person, named as their profile names them", async () => { + const alice = "b".repeat(64); + const npub = npubEncode(alice); + const { setLanding, user } = setup({ names: { [alice]: "Alice" } }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + // Copy npub writes the bare npub. + pasteIntoMessage(npub); + await user.click(await screen.findByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`[@Alice](nostr:${npub})`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("labels a pasted nostr: npub with a short key when the name is unknown", async () => { + const npub = npubEncode("c".repeat(64)); + const { setLanding, user } = setup({ names: {} }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + pasteIntoMessage(`nostr:${npub}`); + await user.click(await screen.findByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`[@npub…${npub.slice(-3)}](nostr:${npub})`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("links an npub pasted into prose", async () => { + const alice = "b".repeat(64); + const npub = npubEncode(alice); + const { setLanding, user } = setup({ names: { [alice]: "Alice" } }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + pasteIntoMessage(npub, "Ask ", " anything"); + await user.click(await screen.findByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`Ask [@Alice](nostr:${npub}) anything`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("keeps an npub pasted as a link destination being typed, so the link completes", async () => { + const alice = "b".repeat(64); + const npub = npubEncode(alice); + const { setLanding, user } = setup({ names: { [alice]: "Alice" } }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + pasteIntoMessage(`nostr:${npub}`, "Ask [@Alice]("); + await user.keyboard(")"); + expect(messageField()).toHaveValue(`Ask [@Alice](nostr:${npub})`); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`Ask [@Alice](nostr:${npub})`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it.each([ + ["inside a code span", "Key: `id ", " here`"], + ["after an unclosed backtick", "Key: `", ""], + ["inside a link label", "[see ", " here](https://example.com)"], + ["completing a bare URL", "https://primal.net/p/", ""], +])("keeps an npub pasted %s as pasted", async (_, before, after) => { + const alice = "b".repeat(64); + const npub = npubEncode(alice); + const { setLanding, user } = setup({ names: { [alice]: "Alice" } }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + pasteIntoMessage(npub, before, after); + expect(messageField()).toHaveValue(before + npub + after); + await user.click(await screen.findByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(before + npub + after), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("keeps an npub pasted into toolbar code as pasted", async () => { + const alice = "b".repeat(64); + const npub = npubEncode(alice); + const { setLanding, user } = setup({ names: { [alice]: "Alice" } }); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + const input = messageField(); + setMessage(""); + act(() => { + input.focus(); + document.dispatchEvent(new Event("selectionchange")); + }); + // A pointer click leaves focus and the caret, with Code on, in the editor. + await user.click(screen.getByRole("button", { name: "Toggle formatting" })); + await user.click(screen.getByRole("button", { name: "Code" })); + expect(input).toHaveFocus(); + act(() => { + fireEvent.paste(input, plainClipboard(npub)); + }); + expect(input.querySelector("code")).toHaveTextContent(npub); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`\`${npub}\``), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +const publishedMarkdown = [ + "**Welcome** to _Primary_", + "", + `- Say hi in [#Town square](buzz://channel/${square})`, + `- Ask [@Ada](${profileTarget("a".repeat(64))}) anything`, +].join("\n"); + +it("opens published Markdown as its source and sends it back unchanged", async () => { + const markdown = publishedMarkdown; + const { setLanding, user } = setup({ + landing: { landing: true, welcome: markdown, name: "Lobby" }, + }); + await waitFor(() => expect(messageField()).toHaveValue(markdown)); + // Nothing changed yet, so there is nothing to save. + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + const input = messageField() as ComposerInputElement; + act(() => { + input.focus(); + input.setSelectionRange(markdown.length, markdown.length); + document.dispatchEvent(new Event("selectionchange")); + }); + await user.keyboard("!"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(`${markdown}!`), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("bolds one word inside published Markdown and keeps the rest of the source", async () => { + const markdown = publishedMarkdown; + const { setLanding, user } = setup({ + landing: { landing: true, welcome: markdown, name: "Lobby" }, + }); + await waitFor(() => expect(messageField()).toHaveValue(markdown)); + const input = messageField() as ComposerInputElement; + const word = "anything"; + const start = markdown.indexOf(word); + act(() => { + input.focus(); + input.setSelectionRange(start, start + word.length); + document.dispatchEvent(new Event("selectionchange")); + }); + // A pointer click keeps the selection in the editor. + await user.click(screen.getByRole("button", { name: "Toggle formatting" })); + await user.click(screen.getByRole("button", { name: "Bold" })); + expect(input).toHaveFocus(); + expect( + [...input.querySelectorAll("strong")].map((node) => node.textContent), + ).toContain(word); + // Only the selected word changes; the emphasis, list and links around it + // are sent as they were published. + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome(markdown.replace(word, `**${word}**`)), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("moves the welcome to another public channel with its full state", async () => { + const { setLanding, user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.click(screen.getByRole("button", { name: "Browse channels" })); + // Only active public stream channels are offered, each with its icon. + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Lobby", + "Random", + "Town square", + ]); + for (const option of options) + expect(option.querySelector("svg")).not.toBeNull(); + await user.click(screen.getByRole("option", { name: "Town square" })); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + square, + welcome("Hi there"), + expect.any(AbortSignal), + expect.any(Function), + ), + ); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); +}); + +it("requires a channel when none is designated and sends the defaults as empty", async () => { + const { readLanding, setLanding, user } = setup({ + info: { policy: null, landingChannels: true }, + }); + await waitFor(() => + expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE), + ); + expect(channel()).toHaveValue(""); + expect(readLanding).not.toHaveBeenCalled(); + setMessage(`${DEFAULT_WELCOME_MESSAGE}\n`); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + await user.type(channel(), "town"); + await user.click(await screen.findByRole("option", { name: "Town square" })); + await user.click(screen.getByRole("button", { name: "Save" })); + // The normalized defaults are never stored as custom text. + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + square, + welcome(""), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("normalizes line endings and trims the title and message before sending", async () => { + const { setLanding, user } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage(" Hi\r\nall "); + fireEvent.change(titleField(), { target: { value: " Hello, newcomer " } }); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome("Hi\nall", "Hello, newcomer"), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("blocks messages the relay would refuse and restores the defaults in the draft", async () => { + const { setLanding, user } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage(" \n "); + expect( + screen.getByText("Enter a welcome message, or restore the defaults."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + setMessage("a".repeat(4001)); + expect( + screen.getByText("Shorten the message to 4,000 characters."), + ).toBeVisible(); + setMessage("a\u0007b"); + expect( + screen.getByText( + "Remove control characters. Line breaks and tabs are fine.", + ), + ).toBeVisible(); + // Unicode scalar values, not UTF-16 units, as the relay counts; tabs pass. + setMessage(`${"😀".repeat(1999)}\t${"😀".repeat(2000)}`); + expect(screen.getByText("4,000 of 4,000 characters")).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + fireEvent.change(titleField(), { target: { value: "Hello" } }); + await user.click(screen.getByRole("button", { name: "Restore defaults" })); + expect(messageField()).toHaveValue(DEFAULT_WELCOME_MESSAGE); + expect(titleField()).toHaveValue("Welcome to Primary"); + expect(screen.queryByRole("button", { name: "Restore defaults" })).toBeNull(); + expect(setLanding).not.toHaveBeenCalled(); +}); + +it("blocks titles the relay would refuse", async () => { + const { setLanding } = setup(); + await waitFor(() => expect(titleField()).toHaveValue("Welcome to Primary")); + fireEvent.change(titleField(), { target: { value: " " } }); + expect( + screen.getByText("Enter a title, or restore the defaults."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + fireEvent.change(titleField(), { target: { value: "a".repeat(121) } }); + expect( + screen.getByText("Shorten the title to 120 characters."), + ).toBeVisible(); + fireEvent.change(titleField(), { target: { value: "a\tb" } }); + expect(screen.getByText("Use one line of text for the title.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + fireEvent.change(titleField(), { target: { value: "a\u2028b" } }); + expect(screen.getByText("Use one line of text for the title.")).toBeVisible(); + fireEvent.change(titleField(), { target: { value: "😀".repeat(120) } }); + expect(screen.getByText("120 of 120 characters")).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + expect(setLanding).not.toHaveBeenCalled(); +}); + +it("blocks invisible-only text and hidden direction characters, as the relay does", async () => { + const { setLanding } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + // Zero-width and braille-blank characters alone leave nothing to read. + setMessage("\u200B\u2800"); + expect( + screen.getByText("Enter a welcome message, or restore the defaults."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + setMessage("\u202Eabc"); + expect( + screen.getByText("Remove the hidden text-direction characters."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + // A zero-width space inside readable text is fine. + setMessage("Hello\u200Bworld"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + fireEvent.change(titleField(), { target: { value: " \u200B " } }); + expect( + screen.getByText("Enter a title, or restore the defaults."), + ).toBeVisible(); + fireEvent.change(titleField(), { target: { value: "Hi\u2066there" } }); + expect( + screen.getByText("Remove the hidden text-direction characters."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(setLanding).not.toHaveBeenCalled(); +}); + +it('titles the default "Welcome" for a community known only by its address', async () => { + // Joined without a published name, the membership holds only the host. + const { setLanding, user } = setup({ + membership: { name: "primary.example" }, + }); + await waitFor(() => expect(titleField()).toHaveValue("Welcome")); + setMessage("Say hello"); + await user.click(screen.getByRole("button", { name: "Save" })); + // An untouched default title is sent as the default. + await waitFor(() => + expect(setLanding).toHaveBeenCalledWith( + lobby, + welcome("Say hello"), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("resends a published title untouched, even one matching this viewer's default", async () => { + // Another viewer may know the community by another name, so the default is + // never inferred from the text: only Restore defaults sends it. + const { setLanding, user } = setup({ + landing: { + landing: true, + welcome: "Hi there", + welcomeTitle: "Welcome to Primary", + name: "Lobby", + }, + }); + await waitFor(() => expect(titleField()).toHaveValue("Welcome to Primary")); + setMessage("Say hello"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenLastCalledWith( + lobby, + welcome("Say hello", "Welcome to Primary"), + expect.any(AbortSignal), + expect.any(Function), + ), + ); + await user.click(screen.getByRole("button", { name: "Restore defaults" })); + expect(titleField()).toHaveValue("Welcome to Primary"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenLastCalledWith( + lobby, + welcome(""), + expect.any(AbortSignal), + expect.any(Function), + ), + ); + // Cleared and typed back in, the same words are a custom title. + fireEvent.change(titleField(), { target: { value: "" } }); + fireEvent.change(titleField(), { target: { value: "Welcome to Primary" } }); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(setLanding).toHaveBeenLastCalledWith( + lobby, + welcome("", "Welcome to Primary"), + expect.any(AbortSignal), + expect.any(Function), + ), + ); +}); + +it("shows the relay's reason and keeps the draft when it refuses", async () => { + const { setLanding, user } = setup(); + setLanding.mockRejectedValueOnce( + new PublishRejected( + "rejected", + "invalid: landing requires an open, unarchived channel without a ttl", + ), + ); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Say hello"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Landing requires an open, unarchived channel without a ttl", + ); + expect(messageField()).toHaveValue("Say hello"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); +}); + +/** Saves, but the relay never showed the result within the readback window. */ +async function unconfirmedSave(fixture: ReturnType) { + fixture.setLanding.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed(new Error("timeout")), + ); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Say hello"); + await fixture.user.click(screen.getByRole("button", { name: "Save" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Not confirmed yet"); + expect(alert).not.toHaveTextContent("Saved"); + // The draft stays, but another save would republish blind. + expect(messageField()).toHaveValue("Say hello"); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); +} + +it("settles an unconfirmed save by reading again, clearing a draft the relay shows", async () => { + const fixture = setup(); + const { readLanding, setLanding, user } = fixture; + await unconfirmedSave(fixture); + readLanding.mockResolvedValue({ + landing: true, + welcome: "Say hello", + name: "Lobby", + }); + await user.click(screen.getByRole("button", { name: "Check again" })); + // The confirmed outcome is announced like a direct save. + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(readLanding).toHaveBeenCalledTimes(2); + expect(setLanding).toHaveBeenCalledTimes(1); + expect(messageField()).toHaveValue("Say hello"); + expect(screen.queryByText(/Not confirmed yet/)).toBeNull(); +}); + +it("keeps the draft in view during the check and reopens Save when the change is missing", async () => { + const fixture = setup(); + const { readLanding, setLanding, user } = fixture; + await unconfirmedSave(fixture); + let release!: (state: ChannelLanding) => void; + readLanding.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await user.click(screen.getByRole("button", { name: "Check again" })); + try { + // The published form stays in view while the relay is read again. + expect(readLanding).toHaveBeenCalledTimes(2); + expect(messageField()).toBeVisible(); + expect(messageField()).toHaveValue("Say hello"); + const check = screen.getByRole("button", { name: "Check again" }); + expect(check).toHaveAttribute("aria-busy", "true"); + expect(check).toHaveFocus(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + } finally { + release({ landing: true, welcome: "Hi there", name: "Lobby" }); + } + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(), + ); + expect(setLanding).toHaveBeenCalledTimes(1); + expect(messageField()).toHaveValue("Say hello"); + expect(messageField()).toHaveFocus(); + expect(screen.queryByText(/Not confirmed yet/)).toBeNull(); + expect(screen.queryByText("Welcome saved")).toBeNull(); +}); + +it("ends an unconfirmed welcome save, with its draft, when the relay stops supporting welcome channels", async () => { + const fixture = setup(); + const { user } = fixture; + await unconfirmedSave(fixture); + vi.mocked(communityRequest).mockResolvedValueOnce({ + policy: null, + landingChannels: false, + }); + await user.click(screen.getByRole("button", { name: "Check again" })); + expect( + await screen.findByText( + "This community’s relay doesn’t support a welcome channel.", + ), + ).toBeVisible(); + // Nothing of the welcome can be saved here now: no notice, check or draft. + expect(screen.queryByText(/Not confirmed yet/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Check again" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull(); + // The starter list stays usable, and takes the focus the check had. + expect(remove("Town square")).toBeEnabled(); + expect(addField()).toHaveFocus(); +}); + +it("announces starter edits, and rests focus in the pane when Cancel or Save takes the action row away", async () => { + const { user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.click(remove("Town square")); + expect( + screen.getByText("#Town square will be removed when you save"), + ).toHaveAttribute("role", "status"); + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + expect( + screen.getByText("#Random will be added when you save"), + ).toHaveAttribute("role", "status"); + // Back to the published flags: each is an undo, not the reverse change. + await user.click(remove("Random")); + expect(screen.getByText("#Random won’t be added")).toHaveAttribute( + "role", + "status", + ); + await user.type(addField(), "town"); + await user.click(await screen.findByRole("option", { name: "Town square" })); + expect( + screen.getByText("#Town square stays a starter channel"), + ).toHaveAttribute("role", "status"); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + await user.click(remove("Town square")); + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Starter changes discarded")).toHaveAttribute( + "role", + "status", + ); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(channel()).toHaveFocus(); + await user.click(remove("Town square")); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + // Save speaks for itself; the edit's announcement is withdrawn. + expect(screen.queryByText(/will be removed when you save/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(channel()).toHaveFocus(); +}); + +it("keeps the starter list, its typed search and focus when a re-read finds the relay no longer supports welcome channels", async () => { + const fixture = setup(); + const { user } = fixture; + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.click(remove("Town square")); + await unconfirmedSave(fixture); + // The relay's support is read again, and answers while the admin is + // searching for a channel to add. + let answer!: () => void; + vi.mocked(communityRequest).mockImplementationOnce( + () => + new Promise((resolve) => { + answer = () => resolve({ policy: null, landingChannels: false }); + }), + ); + await user.click(screen.getByRole("button", { name: "Check again" })); + await waitFor(() => expect(communityRequest).toHaveBeenCalledTimes(2)); + await user.type(addField(), "ran"); + await act(async () => answer()); + expect( + await screen.findByText( + "This community’s relay doesn’t support a welcome channel.", + ), + ).toBeVisible(); + // The same list, not a new one: the search and the pending removal stay. + expect(addField()).toHaveValue("ran"); + expect(addField()).toHaveFocus(); + expect(removable("Town square")).toBeNull(); + // The search's open list hides the action row from role queries. + expect( + screen.getByRole("button", { name: "Save", hidden: true }), + ).toBeEnabled(); + expect( + screen.queryByRole("textbox", { name: "Message", hidden: true }), + ).toBeNull(); +}); + +it("shows Save only for a change, and Cancel returns to the published state", async () => { + const { setLanding, user } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Hi there!"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + // Typing back to the published text leaves nothing to save. + setMessage("Hi there"); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + setMessage("Draft"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(messageField()).toHaveValue("Hi there"); + expect(channel()).toHaveValue("Lobby"); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(setLanding).not.toHaveBeenCalled(); +}); + +it("starts over from the relay when the community's session changes", async () => { + const { readLanding, reconnect } = setup(); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Draft"); + reconnect(); + await waitFor(() => expect(readLanding).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); +}); + +it("abandons a save that is still in flight when the form closes", async () => { + const { setLanding, user, view } = setup(); + let settle!: () => void; + setLanding.mockImplementationOnce( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Say hello"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(setLanding).toHaveBeenCalledTimes(1)); + const [, , signal, active] = setLanding.mock.calls[0] ?? []; + expect(active?.()).toBe(true); + view.unmount(); + // The lifecycle owner checks this guard right before publishing. + expect(signal?.aborted).toBe(true); + expect(active?.()).toBe(false); + settle(); + await act(async () => {}); +}); + +it("offers a retry when the current welcome cannot be read", async () => { + const { readLanding, user } = setup(); + readLanding.mockRejectedValueOnce(new Error("Relay unavailable")); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Welcome settings couldn’t be loaded. Relay unavailable", + ); + await user.click( + screen.getByRole("button", { name: "Retry loading welcome settings" }), + ); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); +}); + +it("lists only the starter channels, the welcome channel fixed first", async () => { + const { user } = setup({ + joined: [ + // Joined channels the directory leaves out are eligible; others are not. + { + id: "66666666-6666-4666-8666-666666666666", + name: "Unlisted", + channelType: "stream", + visibility: "public", + autoJoin: true, + }, + { + id: "77777777-7777-4777-8777-777777777777", + name: "Staff", + channelType: "stream", + visibility: "private", + private: true, + }, + ], + }); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + const group = starters(); + // Each flagged channel is listed and removable; the welcome is fixed. + expect( + within(group) + .getAllByRole("button", { name: /^Remove / }) + .map((button) => button.getAttribute("aria-label")), + ).toEqual([removeLabel("Town square"), removeLabel("Unlisted")]); + const fixed = within(group).getByText("Lobby"); + expect( + within(group).getByText("Welcome channel · always joined"), + ).toBeVisible(); + expect( + fixed.compareDocumentPosition(remove("Town square")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(removable("Lobby")).toBeNull(); + // Channels that are not starters have no row, however many there are. + expect(within(group).queryByText("Random")).toBeNull(); + expect(within(group).queryByText("Staff")).toBeNull(); + expect(group).toHaveTextContent( + "Changes apply when you save, to people who join from then on", + ); + // The search offers the rest: neither the welcome channel nor a starter. + await user.click( + screen.getByRole("button", { name: "Browse channels to add" }), + ); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Random"]); + expect(options[0]?.querySelector("svg")).not.toBeNull(); +}); + +it("browses the first 50 channels by name in both pickers and finds the rest by searching", async () => { + // Sixty more public channels, each sorting before the fixture's own. + const many = Array.from({ length: 60 }, (_, index) => { + const n = String(index + 1).padStart(2, "0"); + return { + id: `aaaaaa${n}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`, + name: `Channel ${n}`, + channelType: "stream" as const, + }; + }); + const { user } = setup({ directory: [...rows, ...many] }); + const hint = "Showing the first 50 channels. Type a name to find more."; + const names = () => + screen.getAllByRole("option").map((option) => option.textContent); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + // The welcome channel picker: 63 eligible channels, the first 50 shown, + // also while the field still shows the selected channel's name. + await user.click(screen.getByRole("button", { name: "Browse channels" })); + await waitFor(() => expect(names()).toHaveLength(50)); + expect(channel()).toHaveValue("Lobby"); + expect(names()[0]).toBe("Channel 01"); + expect(names()[49]).toBe("Channel 50"); + expect(screen.getByText(hint)).toBeVisible(); + // Reopened by clicking the field, the selection still browses 50. + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryAllByRole("option")).toHaveLength(0)); + await user.click(channel()); + await waitFor(() => expect(names()).toHaveLength(50)); + expect(channel()).toHaveValue("Lobby"); + // A search covers every channel, including those past the first 50. + await user.clear(channel()); + await user.type(channel(), "town"); + await waitFor(() => expect(names()).toEqual(["Town square"])); + expect(screen.queryByText(hint)).toBeNull(); + await user.keyboard("{Escape}"); + // The starter search: 61 addable channels, without the welcome or a starter. + await user.click( + screen.getByRole("button", { name: "Browse channels to add" }), + ); + await waitFor(() => expect(names()).toHaveLength(50)); + expect(names()[0]).toBe("Channel 01"); + expect(screen.getByText(hint)).toBeVisible(); + await user.type(addField(), "random"); + await waitFor(() => expect(names()).toEqual(["Random"])); + expect(screen.queryByText(hint)).toBeNull(); +}); + +/** Holds the next starter command until `release`, as the relay confirms. */ +function holdRun( + run: ReturnType["run"], + flag: ReturnType["flag"], +) { + let release!: () => void; + run.mockImplementationOnce( + (action, id) => + new Promise((resolve) => { + release = () => { + flag(id, action === "auto-join-on"); + resolve(); + }; + }), + ); + return () => act(async () => release()); +} + +it("adds a starter to the draft and publishes it, after the welcome, on Save", async () => { + const { flag, run, setLanding, user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + // Only the draft changes: the row is listed as not saved yet. + expect(run).not.toHaveBeenCalled(); + expect(addField()).toHaveValue(""); + expect(within(starters()).getByText("Added · not saved yet")).toBeVisible(); + expect(remove("Random")).toBeEnabled(); + // A starter change alone offers Save. + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + setMessage("Say hello"); + const release = holdRun(run, flag); + await user.click(screen.getByRole("button", { name: "Save" })); + try { + await waitFor(() => + expect(run).toHaveBeenCalledWith( + "auto-join-on", + random, + expect.any(AbortSignal), + expect.any(Function), + ), + ); + // The welcome is published first, then each starter flag. + expect(setLanding.mock.invocationCallOrder[0]).toBeLessThan( + run.mock.invocationCallOrder[0] ?? 0, + ); + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( + "aria-busy", + "true", + ); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(remove("Random")).toBeDisabled(); + expect(remove("Town square")).toBeDisabled(); + } finally { + await release(); + } + // One notice for the whole save, and the row is now published. + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(screen.queryByText("Added #Random")).toBeNull(); + expect(within(starters()).queryByText("Added · not saved yet")).toBeNull(); + expect(remove("Random")).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(run).toHaveBeenCalledOnce(); + await user.click( + screen.getByRole("button", { name: "Browse channels to add" }), + ); + expect(await screen.findByText("No matching public channels.")).toBeVisible(); +}); + +it("drops a removed starter from the list until Cancel, and publishes the removal on Save", async () => { + const { run, setLanding, user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.click(remove("Town square")); + // The row leaves with its button, so focus moves to the search. + expect(run).not.toHaveBeenCalled(); + expect(removable("Town square")).toBeNull(); + expect(addField()).toHaveFocus(); + expect(within(starters()).getByRole("status")).toHaveTextContent( + "No other channels added yet", + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(remove("Town square")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + await user.click(remove("Town square")); + // Adding back a drafted removal leaves nothing to save. + await user.type(addField(), "town"); + await user.click(await screen.findByRole("option", { name: "Town square" })); + expect(within(starters()).queryByText("Added · not saved yet")).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + await user.click(remove("Town square")); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + // An unchanged welcome is not republished. + expect(setLanding).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledExactlyOnceWith( + "auto-join-off", + square, + expect.any(AbortSignal), + expect.any(Function), + ); + expect(removable("Town square")).toBeNull(); + // Removed, it can be added again. + await user.click( + screen.getByRole("button", { name: "Browse channels to add" }), + ); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Random", + "Town square", + ]); +}); + +it("follows the roster for a joined channel once its flag is saved", async () => { + const unlisted: ChannelSummary = { + id: "66666666-6666-4666-8666-666666666666", + name: "Unlisted", + channelType: "stream", + visibility: "public", + }; + const { run, setJoined, user } = setup({ joined: [unlisted] }); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + await user.type(addField(), "unl"); + await user.click(await screen.findByRole("option", { name: "Unlisted" })); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(run).toHaveBeenCalledOnce(); + // The readback republished the joined channel; that is what the list shows. + expect(remove("Unlisted")).toBeVisible(); + expect(within(starters()).queryByText("Added · not saved yet")).toBeNull(); + // Another admin's later change shows too; nothing kept here overrides it. + setJoined([unlisted]); + expect(removable("Unlisted")).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); +}); + +it("explains a refused removal beside its row and a refused addition on the search", async () => { + const { run, user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + run.mockRejectedValueOnce(new PublishRejected("rejected")); + await user.click(remove("Town square")); + await user.click(screen.getByRole("button", { name: "Save" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Only community owners and admins can change this.", + ); + // The refused removal is listed again, its reason after its row and + // before the search; nothing is left to save. + expect( + remove("Town square").compareDocumentPosition(alert) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + alert.compareDocumentPosition(addField()) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(screen.queryByText("Welcome saved")).toBeNull(); + // Removing it again clears the reason. + await user.click(remove("Town square")); + expect(screen.queryByRole("alert")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + // A refused addition has no row, so the search explains it. + run.mockRejectedValueOnce(new PublishRejected("rejected")); + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "#Random: Only community owners and admins can change this.", + ); + expect(removable("Random")).toBeNull(); + // The reason is on the search, and so is the focus Save had. + expect(addField()).toHaveFocus(); + // Adding it again clears the reason. + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + expect(remove("Random")).toBeEnabled(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("publishes the steps before a refusal and keeps the ones after it in the draft", async () => { + const unlisted: ChannelSummary = { + id: "66666666-6666-4666-8666-666666666666", + name: "Unlisted", + channelType: "stream", + visibility: "public", + }; + const { run, setLanding, user } = setup({ joined: [unlisted] }); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + setMessage("Say hello"); + // Steps run by channel name: Random, Town square, then Unlisted. + await user.type(addField(), "ran"); + await user.click(await screen.findByRole("option", { name: "Random" })); + await user.click(remove("Town square")); + await user.type(addField(), "unl"); + await user.click(await screen.findByRole("option", { name: "Unlisted" })); + const confirm = run.getMockImplementation(); + if (!confirm) throw new Error("The fixture confirms starter commands"); + run + .mockImplementationOnce(confirm) + .mockRejectedValueOnce( + new PublishRejected("rejected", "invalid: channel is busy"), + ); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Channel is busy"); + expect(setLanding).toHaveBeenCalledOnce(); + expect(run.mock.calls.map(([action, id]) => [action, id])).toEqual([ + ["auto-join-on", random], + ["auto-join-off", square], + ]); + // The welcome is published, so its draft is gone. + expect(messageField()).toHaveValue("Say hello"); + expect(screen.queryByText("Welcome saved")).toBeNull(); + // Random is published, the refused removal is listed again with its + // reason, and the step after it waits in the draft. + expect(remove("Random")).toBeVisible(); + expect(remove("Town square")).toBeVisible(); + const drafted = within(starters()).getAllByText("Added · not saved yet"); + expect(drafted).toHaveLength(1); + expect(remove("Unlisted").closest(".buzz-preference-row")).toHaveTextContent( + "Added · not saved yet", + ); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(setLanding).toHaveBeenCalledOnce(); + expect(run.mock.calls.slice(2).map(([action, id]) => [action, id])).toEqual([ + ["auto-join-on", unlisted.id], + ]); + expect(within(starters()).queryByText("Added · not saved yet")).toBeNull(); +}); + +it("checks an unconfirmed starter step by reading its channel again", async () => { + const { flag, readLanding, run, user } = setup(); + await waitFor(() => expect(channel()).toHaveValue("Lobby")); + readLanding.mockClear(); + run.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed(new Error("timeout")), + ); + await user.click(remove("Town square")); + await user.click(screen.getByRole("button", { name: "Save" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Not confirmed yet. The relay hasn’t shown the change to #Town square; check before saving again.", + ); + // The removal stays drafted, but another save would republish blind. + expect(removable("Town square")).toBeNull(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + // The relay does not show it yet: Save opens again for the same draft. + readLanding.mockResolvedValueOnce({ name: "Town square", autoJoin: true }); + await user.click(screen.getByRole("button", { name: "Check again" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(), + ); + expect(readLanding).toHaveBeenCalledExactlyOnceWith( + square, + expect.any(AbortSignal), + ); + expect(screen.queryByText(/Not confirmed yet/)).toBeNull(); + expect(screen.queryByText("Welcome saved")).toBeNull(); + expect(removable("Town square")).toBeNull(); + // Unconfirmed again, and this time the check finds it. + run.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed(new Error("timeout")), + ); + await user.click(screen.getByRole("button", { name: "Save" })); + await screen.findByText(/Not confirmed yet/); + readLanding.mockImplementationOnce(async () => { + flag(square, false); + return { name: "Town square" }; + }); + await user.click(screen.getByRole("button", { name: "Check again" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(screen.queryByText(/Not confirmed yet/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + expect(removable("Town square")).toBeNull(); + // The welcome form was not read again. + expect(communityRequest).toHaveBeenCalledOnce(); +}); + +it("holds starter edits while the welcome saves", async () => { + const { setLanding, user } = setup(); + let settle!: () => void; + setLanding.mockImplementationOnce( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + await waitFor(() => expect(messageField()).toHaveValue("Hi there")); + setMessage("Say hello"); + await user.click(screen.getByRole("button", { name: "Save" })); + try { + expect(remove("Town square")).toBeDisabled(); + await user.click( + screen.getByRole("button", { name: "Browse channels to add" }), + ); + expect( + await screen.findByRole("option", { name: "Random" }), + ).toHaveAttribute("aria-disabled", "true"); + } finally { + await act(async () => settle()); + } + // The browse list is still open, hiding the rows from role queries. + expect( + screen.getByRole("button", { + name: removeLabel("Town square"), + hidden: true, + }), + ).toBeEnabled(); +}); + +it("still lists and saves starter channels when the relay has no welcome channel", async () => { + const { readLanding, run, user } = setup({ + info: { policy: null, landingChannels: false }, + }); + expect( + await screen.findByText( + "This community’s relay doesn’t support a welcome channel.", + ), + ).toBeVisible(); + await waitFor(() => expect(remove("Town square")).toBeVisible()); + expect(addField()).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull(); + expect(screen.queryByText("Welcome channel · always joined")).toBeNull(); + expect(readLanding).not.toHaveBeenCalled(); + await user.click(remove("Town square")); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Welcome saved")).toBeInTheDocument(); + expect(run).toHaveBeenCalledExactlyOnceWith( + "auto-join-off", + square, + expect.any(AbortSignal), + expect.any(Function), + ); + expect(removable("Town square")).toBeNull(); + // With no welcome form, focus from the departed Save rests on the search. + expect(addField()).toHaveFocus(); +}); diff --git a/src/bundled/moderation/WelcomeSettings.tsx b/src/bundled/moderation/WelcomeSettings.tsx new file mode 100644 index 0000000000..1c48aaba19 --- /dev/null +++ b/src/bundled/moderation/WelcomeSettings.tsx @@ -0,0 +1,1071 @@ +import { + Fragment, + type RefObject, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ComposerInputElement } from "../../features/messages/composer-dom"; +import { + communityRequest, + type CommunityInfo, +} from "../../features/communities/api"; +import type { CommunityReader } from "../../features/communities/service"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import { + DEFAULT_WELCOME_MESSAGE, + useDefaultWelcomeTitle, +} from "../../features/communities/WelcomeCard"; +import { channelIcon } from "../../features/channels/channel-icon"; +import { useChannelDirectory } from "../../features/channels/useChannelDirectory"; +import { ChannelLifecycleUnconfirmed } from "../../features/relay/channel-lifecycle"; +import type { WelcomeCommand } from "../../features/relay/channel-lifecycle-protocol"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { communityFromScope } from "../../features/relay/gifs"; +import { + normalizeWelcome, + WELCOME_MAX_LENGTH, + WELCOME_TITLE_MAX_LENGTH, + welcomeTextProblem, + welcomeTitleProblem, +} from "../../features/relay/landing"; +import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; +import { + useChannelRoster, + useRelayConnection, +} from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { matchName } from "../../features/search/match"; +import { flushSync } from "react-dom"; +import { Button } from "../../shared/design-system/ui/Button"; +import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; +import { Combobox } from "../../shared/design-system/ui/Combobox"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Header, InlineHeader } from "../../shared/design-system/ui/Header"; +import { Input } from "../../shared/design-system/ui/Input"; +import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { ToastNotice } from "../../shared/design-system/ui/Toast"; +import { WelcomeMessageEditor } from "./WelcomeMessageEditor"; +import styles from "./WelcomeSettings.module.css"; + +const message = (reason: unknown) => + reason instanceof Error ? reason.message : String(reason); +const MAX = WELCOME_MAX_LENGTH.toLocaleString("en-US"); +const TITLE_MAX = WELCOME_TITLE_MAX_LENGTH.toLocaleString("en-US"); +// Every listed channel is an open stream, so each shows the public icon. +const ChannelIcon = channelIcon({ channelType: "stream" }); + +/** + * The default message is never stored: a draft matching it restores it. The + * default title depends on the viewer, so it is never inferred: an untouched + * title resends what is published, Restore defaults (`null`) sends the + * default, and an edited title is sent as written. + */ +function commandFor( + draft: Readonly<{ text: string; title?: string | null }>, + publishedTitle: string, +): WelcomeCommand { + const text = normalizeWelcome(draft.text); + return { + message: text === DEFAULT_WELCOME_MESSAGE ? "" : text, + title: + draft.title === undefined + ? publishedTitle + : draft.title === null + ? "" + : draft.title.trim(), + }; +} + +/** What a definite failure means for the draft, which is always kept. */ +function saveError(error: unknown) { + if (error instanceof PublishNotSent) return "Not sent. Try again."; + if (error instanceof PublishRejected) { + const reason = error.reason?.replace(/^invalid:\s*/, "").trim(); + return reason + ? `${reason.charAt(0).toUpperCase()}${reason.slice(1)}` + : "Only community owners and admins can change this."; + } + return message(error); +} + +const bidi = "Remove the hidden text-direction characters."; +const problems = { + empty: "Enter a welcome message, or restore the defaults.", + bidi, + long: `Shorten the message to ${MAX} characters.`, + control: "Remove control characters. Line breaks and tabs are fine.", +}; +const titleProblems = { + empty: "Enter a title, or restore the defaults.", + bidi, + long: `Shorten the title to ${TITLE_MAX} characters.`, + control: "Use one line of text for the title.", +}; + +/** Community settings: where new members start, what they read there, and + * which other channels they join. */ +export function WelcomeSettings({ + relay, + communities, + active, + extensions, +}: { + relay: RelayData; + /** Names the community in the default title. */ + communities?: CommunityReader | undefined; + active(): boolean; + /** Presents pasted links in the message editor as messages show them. */ + extensions?: ConversationExtensions | undefined; +}) { + const connection = useRelayConnection(relay); + const community = + connection.status === "ready" && connection.scope + ? communityFromScope(connection.scope) + : null; + return ( +
+
+ {community && connection.scope ? ( + + ) : ( +

Choose a connected community to set its welcome.

+ )} +
+ ); +} + +/** The relay's current designation; `welcome` and `title` are `""` for the + * defaults. */ +type Published = Readonly<{ + channelId?: string; + name?: string; + welcome: string; + title: string; +}>; +type Load = + | { status: "loading" } + | { status: "ready"; published: Published } + | { status: "unsupported" } + | { status: "error"; error: string }; +type Option = Readonly<{ + id: string; + name: string; + /** Published: a joined channel's follows the roster, another's the + * directory, and both take a confirmed change at once. */ + autoJoin: boolean; + /** In the viewer's roster, which follows the relay's metadata. */ + joined: boolean; +}>; +/** `title` is absent until edited; `null` once Restore defaults is pressed. */ +type Draft = Readonly<{ + channelId?: string; + text: string; + title?: string | null; +}>; +/** One starter flag Save sets. */ +type StarterStep = Readonly<{ id: string; name: string; enabled: boolean }>; +/** Drafted starter flags by channel; Save sets each that differs from the + * published one. */ +type StarterDraft = ReadonlyMap; + +/** Browsing with no search lists this many channels by name; a search covers + * every channel, so a community with thousands still opens a short list. */ +const BROWSE_LIMIT = 50; + +/** The options whose names match `needle`, best first; for no needle, the + * first {@link BROWSE_LIMIT} by name. */ +function ranked(options: readonly Option[], needle: string) { + if (!needle) return options.slice(0, BROWSE_LIMIT); + return options + .flatMap((option) => { + const rank = matchName(option.name, needle)?.rank; + return rank === undefined ? [] : [{ option, rank }]; + }) + .sort((a, b) => a.rank - b.rank) + .map(({ option }) => option); +} + +/** Follows the results of a browse that {@link ranked} cut short. Like the + * popup's empty line it stays mounted while the popup is open, so clearing a + * search announces it. */ +function BrowseLimit({ capped }: { capped: boolean }) { + return ( +
+ {capped + ? `Showing the first ${BROWSE_LIMIT} channels. Type a name to find more.` + : null} +
+ ); +} + +/** Open, active stream channels: the directory's, joined ones it may leave + * out (such as unlisted ones), and the current welcome channel. The joined + * list is live, so its flags win. */ +function eligibleChannels( + directory: ReturnType["directory"], + joined: readonly ChannelSummary[], + current: Published | undefined, +) { + const rows = new Map(); + for (const channel of directory) + if (channel.channelType === "stream" && !channel.archived) + rows.set(channel.id, { + id: channel.id, + name: channel.name, + autoJoin: !!channel.autoJoin, + joined: false, + }); + for (const channel of joined) + if ( + channel.channelType === "stream" && + channel.visibility === "public" && + !channel.archived && + !channel.huddle + ) + rows.set(channel.id, { + id: channel.id, + name: channel.name, + autoJoin: !!channel.autoJoin, + joined: true, + }); + const id = current?.channelId; + if (id && !rows.has(id)) + rows.set(id, { + id, + name: current.name ?? id.slice(0, 8), + autoJoin: false, + joined: false, + }); + return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The Check again button leaves with its notice; focus stays in the form, + * on the first of `targets` shown. */ +function stayInForm( + check: HTMLElement | null, + ...targets: readonly (HTMLElement | null)[] +) { + if (document.activeElement === check) + targets.find(Boolean)?.focus({ preventScroll: true }); +} + +const without = (map: ReadonlyMap, id: string) => { + if (!map.has(id)) return map; + const next = new Map(map); + next.delete(id); + return next; +}; + +function WelcomeEditor({ + session, + scope, + community, + communities, + active, + extensions, +}: { + session: RelaySession; + scope: string; + community: string; + communities: CommunityReader | undefined; + active(): boolean; + extensions: ConversationExtensions | undefined; +}) { + const lifecycle = session.channelLifecycle; + const defaultTitle = useDefaultWelcomeTitle(communities, scope); + const formId = useId(); + const [attempt, setAttempt] = useState(0); + const [load, setLoad] = useState({ status: "loading" }); + const [draft, setDraft] = useState(); + const [starterDraft, setStarterDraft] = useState( + () => new Map(), + ); + const [typed, setTyped] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + // A failed starter step's reason: a removal's beside its row, an addition's + // on the add control, since its row is gone. + const [starterErrors, setStarterErrors] = useState< + ReadonlyMap + >(() => new Map()); + const [addError, setAddError] = + useState>(); + // An unconfirmed publication, of the welcome or of one starter flag: only a + // fresh read, never a repeat, settles it. + const [unconfirmed, setUnconfirmed] = useState<"welcome" | StarterStep>(); + const [checking, setChecking] = useState(false); + const [saved, setSaved] = useState(false); + // Starter edits change the list without moving focus; this tells of them. + const [announcement, setAnnouncement] = useState(""); + // Cleared first, so the same words said twice are heard twice. + const announce = (text: string) => { + flushSync(() => setAnnouncement("")); + setAnnouncement(text); + }; + // The check compares the relay's answer with the draft as it stands then. + const draftRef = useRef(draft); + draftRef.current = draft; + // The starter steps Save would run, for a check deciding whether all landed. + const pendingSteps = useRef([]); + const checkButton = useRef(null); + const channelInput = useRef(null); + const messageInput = useRef(null); + const addInput = useRef(null); + const actionsRow = useRef(null); + const mounted = useRef(true); + const saveController = useRef(undefined); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + saveController.current?.abort(); + }; + }, []); + // Each open reads the live designation, then its signed welcome. + useEffect(() => { + const reading = new AbortController(); + // A check keeps the published form in view; only the first read waits. + const check = attempt > 0; + setLoad((current) => + check && current.status === "ready" ? current : { status: "loading" }, + ); + void (async () => { + try { + const info = await communityRequest( + community, + "info", + undefined, + reading.signal, + ); + reading.signal.throwIfAborted(); + if (!info.landingChannels) { + setLoad({ status: "unsupported" }); + // The welcome can no longer be saved here, so its draft and an + // unconfirmed save of it end with this read. + setDraft(undefined); + setUnconfirmed((current) => + current === "welcome" ? undefined : current, + ); + setChecking(false); + if (check) stayInForm(checkButton.current, addInput.current); + return; + } + const id = info.landingChannel; + const state = id + ? await lifecycle.readLanding(id, reading.signal) + : undefined; + reading.signal.throwIfAborted(); + const published: Published = { + ...(id && state ? { channelId: id } : {}), + ...(state?.name ? { name: state.name } : {}), + welcome: (state?.landing && state.welcome) || "", + title: (state?.landing && state.welcomeTitle) || "", + }; + // A draft the relay now shows has landed; there is nothing to save. + const current = draftRef.current; + const wanted = current && commandFor(current, published.title); + const landed = + !!wanted && + current?.channelId === published.channelId && + wanted.message === published.welcome && + wanted.title === published.title; + setLoad({ status: "ready", published }); + if (landed) { + setDraft(undefined); + // Starter changes still wait for Save. + if (!pendingSteps.current.length) setSaved(true); + } + setUnconfirmed(undefined); + setChecking(false); + if (check) + stayInForm( + checkButton.current, + messageInput.current, + addInput.current, + ); + } catch (reason) { + if (reading.signal.aborted) return; + setChecking(false); + setLoad({ status: "error", error: message(reason) }); + } + })(); + return () => reading.abort(); + }, [community, lifecycle, attempt]); + const published = load.status === "ready" ? load.published : undefined; + const supported = load.status !== "unsupported"; + const listing = load.status === "ready" || !supported; + const directory = useChannelDirectory(session.channels, listing); + const roster = useChannelRoster(session.channels); + const options = useMemo( + () => eligibleChannels(directory.directory, roster.channels, published), + [directory.directory, roster.channels, published], + ); + const channelId = draft ? draft.channelId : published?.channelId; + const text = draft?.text ?? (published?.welcome || DEFAULT_WELCOME_MESSAGE); + const titleDraft = draft?.title; + const title = + typeof titleDraft === "string" + ? titleDraft + : titleDraft === null || !published?.title + ? defaultTitle + : published.title; + const normalized = normalizeWelcome(text); + const trimmedTitle = title.trim(); + const command = commandFor( + { text, ...(titleDraft !== undefined ? { title: titleDraft } : {}) }, + published?.title ?? "", + ); + const problem = welcomeTextProblem(normalized); + // Only an edited title needs checking: a published one passed the relay, and + // the default is never sent. + const titleProblem = + typeof titleDraft === "string" + ? welcomeTitleProblem(trimmedTitle) + : undefined; + const welcomeDirty = + !!published && + (channelId !== published.channelId || + command.message !== published.welcome || + command.title !== published.title); + // The welcome channel's own flag is left as it is. A drafted flag the relay + // already shows, as after a check, needs no step. + const welcomeChannel = published?.channelId + ? options.find((option) => option.id === published.channelId) + : undefined; + const steps = useMemo( + () => + options.flatMap((option): StarterStep[] => { + const enabled = starterDraft.get(option.id); + return enabled === undefined || + enabled === option.autoJoin || + option.id === welcomeChannel?.id + ? [] + : [{ id: option.id, name: option.name, enabled }]; + }), + [options, starterDraft, welcomeChannel?.id], + ); + pendingSteps.current = steps; + const dirty = welcomeDirty || steps.length > 0; + const invalid = welcomeDirty && (!channelId || !!problem || !!titleProblem); + const selected = options.find((option) => option.id === channelId) ?? null; + // The input shows the chosen name; browsing from there lists every channel. + const needle = typed === selected?.name ? "" : typed.trim().toLowerCase(); + const matches = useMemo(() => ranked(options, needle), [options, needle]); + const edit = (patch: Partial) => { + setDraft({ + ...(channelId ? { channelId } : {}), + text, + ...(titleDraft !== undefined ? { title: titleDraft } : {}), + ...patch, + }); + setSaved(false); + setError(""); + }; + const editStarter = (channel: Option, enabled: boolean) => { + // Back to the published flag: the drafted change is undone, not reversed. + const undo = enabled === channel.autoJoin; + setStarterDraft((current) => + undo + ? without(current, channel.id) + : new Map(current).set(channel.id, enabled), + ); + setStarterErrors((current) => without(current, channel.id)); + if (enabled || addError?.id === channel.id) setAddError(undefined); + announce( + undo + ? enabled + ? `#${channel.name} stays a starter channel` + : `#${channel.name} won’t be added` + : `#${channel.name} will be ${enabled ? "added" : "removed"} when you save`, + ); + setSaved(false); + setError(""); + }; + // Cancel and Save take the action row away; focus on it rests on the + // welcome channel field, or the add field where there is no welcome form. + const restFocus = () => + (channelInput.current ?? addInput.current)?.focus({ preventScroll: true }); + const actionsShown = useRef(false); + const isActive = () => mounted.current && active(); + // The welcome first, then each starter flag in turn. A confirmed step is + // published; a refused one returns to its published state, where its reason + // shows; the steps after it stay in the draft. + const save = () => { + if (saving || !dirty || invalid || unconfirmed || !lifecycle.available) + return; + const welcome = + welcomeDirty && channelId + ? { + target: channelId, + sent: command, + name: options.find((option) => option.id === channelId)?.name, + } + : undefined; + const pending = steps; + const owner = new AbortController(); + saveController.current = owner; + setSaving(true); + setSaved(false); + setError(""); + setStarterErrors(new Map()); + setAddError(undefined); + setAnnouncement(""); + void (async () => { + let step: StarterStep | undefined; + try { + if (welcome) { + await lifecycle.setLanding( + welcome.target, + welcome.sent, + owner.signal, + isActive, + ); + if (!mounted.current) return; + setLoad({ + status: "ready", + published: { + channelId: welcome.target, + ...(welcome.name ? { name: welcome.name } : {}), + welcome: welcome.sent.message, + title: welcome.sent.title, + }, + }); + setDraft(undefined); + } + for (const next of pending) { + step = next; + await lifecycle.run( + next.enabled ? "auto-join-on" : "auto-join-off", + next.id, + owner.signal, + isActive, + ); + if (!mounted.current) return; + // The confirmed readback already updated the published flag. + setStarterDraft((current) => without(current, next.id)); + } + setSaved(true); + } catch (reason) { + if (!mounted.current || owner.signal.aborted) return; + if (reason instanceof ChannelLifecycleUnconfirmed) + setUnconfirmed(step ?? "welcome"); + else if (!step) setError(saveError(reason)); + else { + const { id, enabled } = step; + setStarterDraft((current) => without(current, id)); + if (enabled) { + setAddError({ id, message: saveError(reason) }); + // The reason shows on the add field; focus from Save follows it. + if (actionsRow.current?.contains(document.activeElement)) + addInput.current?.focus({ preventScroll: true }); + } else setStarterErrors(new Map([[id, saveError(reason)]])); + } + } finally { + if (mounted.current) setSaving(false); + } + })(); + }; + // A starter step is checked by a strong read of its channel, which also + // updates the published flag; the welcome, by reading the card again. + const check = () => { + setChecking(true); + const step = unconfirmed; + if (typeof step !== "object") { + setAttempt((n) => n + 1); + return; + } + const owner = new AbortController(); + saveController.current = owner; + setError(""); + void (async () => { + try { + const state = await lifecycle.readLanding(step.id, owner.signal); + if (!mounted.current) return; + if (!!state?.autoJoin === step.enabled) { + setStarterDraft((current) => without(current, step.id)); + if ( + !draftRef.current && + !pendingSteps.current.some(({ id }) => id !== step.id) + ) + setSaved(true); + } + setUnconfirmed(undefined); + stayInForm(checkButton.current, messageInput.current, addInput.current); + } catch (reason) { + if (!mounted.current || owner.signal.aborted) return; + setError(message(reason)); + } finally { + if (mounted.current) setChecking(false); + } + })(); + }; + const disabled = saving || !published || !lifecycle.available; + const starters = ( + + ); + const actions = (draft !== undefined || steps.length > 0) && ( +
+ + +
+ ); + const footer = (error || unconfirmed || actions) && ( +
+ {error && ( +

+ {error} +

+ )} + {unconfirmed && ( +
+

+ Not confirmed yet. The relay hasn’t shown{" "} + {unconfirmed === "welcome" + ? "this change" + : `the change to #${unconfirmed.name}`} + ; check before saving again. +

+
+ +
+
+ )} + {actions} +
+ ); + // A Save or Cancel that was focused leaves with the row; an element that + // stays takes the focus, which would otherwise fall to the document. + useLayoutEffect(() => { + const shown = !!actions; + if ( + actionsShown.current && + !shown && + (!document.activeElement || document.activeElement === document.body) + ) + restFocus(); + actionsShown.current = shown; + }); + const toast = saved && ( + setSaved(false)} + /> + ); + const status = ( +

+ {announcement} +

+ ); + return ( + +

+ Welcome settings couldn’t be loaded. {load.error} +

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

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

+ ) : undefined + } + > + {/* One container either way, so the starter list, its live region and + their focus survive the relay's support changing on a re-read. */} +
+ {supported && ( + +
{ + event.preventDefault(); + if (!disabled) save(); + }} + > + + items={options} + filteredItems={matches} + // Until something is typed, a field showing the chosen name + // lists `items` instead of `filteredItems`; cap that browse too. + limit={BROWSE_LIMIT} + value={selected} + onValueChange={(option) => { + if (option) edit({ channelId: option.id }); + }} + onInputValueChange={setTyped} + itemToStringLabel={(option) => option.name} + itemToStringValue={(option) => option.id} + isItemEqualToValue={(a, b) => a.id === b.id} + disabled={disabled} + autoHighlight + > +
+ )} + {starters} + {status} + {footer} + {toast} +
+
+ ); +} + +/** + * Connected objects: the channels whose relay `auto_join` flag is on, and a + * search that adds another. Only starter channels are listed, so the list + * stays short however many channels the community has. Add and Remove edit + * the draft; Save publishes them. A drafted addition is listed as not saved + * yet, and a drafted removal leaves the list until Cancel. Community owners + * and admins may also flag channels they have not joined. The welcome channel + * is always joined first, so its row is fixed and its own flag is left as it + * is. + */ +function StarterChannels({ + channels, + welcomeChannel, + draft, + onChange, + available, + saving, + errors, + addError, + addInput, + loading, + failed, + retry, +}: { + channels: readonly Option[]; + welcomeChannel?: Option | undefined; + draft: StarterDraft; + onChange(channel: Option, enabled: boolean): void; + available: boolean; + saving: boolean; + errors: ReadonlyMap; + addError?: Readonly<{ id: string; message: string }> | undefined; + addInput: RefObject; + loading: boolean; + failed: boolean; + /** Offered here only when nothing else on the card offers it. */ + retry?: (() => void) | undefined; +}) { + const heading = useId(); + const [typed, setTyped] = useState(""); + const { starters, addable } = useMemo(() => { + const starters: Option[] = []; + const addable: Option[] = []; + for (const channel of channels) + if (channel.id !== welcomeChannel?.id) + ((draft.get(channel.id) ?? channel.autoJoin) ? starters : addable).push( + channel, + ); + return { starters, addable }; + }, [channels, welcomeChannel?.id, draft]); + const needle = typed.trim().toLowerCase(); + const matches = useMemo(() => ranked(addable, needle), [addable, needle]); + const capped = !needle && addable.length > BROWSE_LIMIT; + const failedAdd = + addError && !starters.some((channel) => channel.id === addError.id) + ? `#${channels.find((channel) => channel.id === addError.id)?.name ?? addError.id.slice(0, 8)}: ${addError.message}` + : undefined; + const busy = saving || !available; + const empty = starters.length + ? undefined + : loading + ? "Loading channels…" + : failed + ? undefined + : welcomeChannel + ? "No other channels added yet" + : "No channels added yet"; + return ( +
+ + {(welcomeChannel || starters.length > 0 || empty) && ( + + {welcomeChannel && ( + } + title={welcomeChannel.name} + subtitle="Welcome channel · always joined" + /> + )} + {starters.map((channel) => { + const error = errors.get(channel.id); + return ( + + } + title={channel.name} + subtitle={ + channel.autoJoin ? undefined : "Added · not saved yet" + } + trailing={ + + } + /> + {error && ( +

+ {error} +

+ )} +
+ ); + })} + {empty && ( + {empty}} /> + )} +
+ )} + + items={addable} + filteredItems={matches} + value={null} + inputValue={typed} + onInputValueChange={(next, details) => { + if ( + details.reason === "input-change" || + details.reason === "input-clear" + ) + setTyped(next); + }} + onValueChange={(option) => { + if (!option) return; + setTyped(""); + onChange(option, true); + }} + itemToStringLabel={(option) => option.name} + itemToStringValue={(option) => option.id} + isItemEqualToValue={(a, b) => a.id === b.id} + disabled={!available} + autoHighlight + > + + + + {(option: Option) => ( + + } + label={option.name} + /> + + )} + + + + + {failed && retry && ( +
+ +
+ )} +
+ ); +} diff --git a/src/bundled/moderation/index.tsx b/src/bundled/moderation/index.tsx index d7e6055cc1..e9e602f098 100644 --- a/src/bundled/moderation/index.tsx +++ b/src/bundled/moderation/index.tsx @@ -1,11 +1,37 @@ import type { PluginModule } from "../../plugins/api"; import { CommunityAdmin } from "./CommunityAdmin"; import { canManageMembership } from "./membership"; +import { WelcomeSettings } from "./WelcomeSettings"; -export const inject = ["relay", "settingsCards", "adminAccess", "navigation"]; +export const inject = [ + "relay", + "settingsCards", + "adminAccess", + "navigation", + "communityReader", + "conversation", +]; export const apply: PluginModule["apply"] = (ctx) => { const relay = ctx.relay; const membership = ctx.adminAccess.membership; + const manageable = () => { + const connection = relay.snapshot(); + const state = membership.snapshot(); + if (canManageMembership(state)) return true; + if ( + connection.status === "error" || + connection.status === "disconnected" || + state.error + ) + return "error"; + if ( + state.status === "idle" || + state.status === "loading" || + state.refreshing + ) + return "pending"; + return false; + }; // Registered in every build. The card hides unsupported member changes // itself, so a Settings section, history entry or in-app locator naming it // still opens the roster and invite controls in native builds. @@ -14,24 +40,7 @@ export const apply: PluginModule["apply"] = (ctx) => { title: "Membership", section: "administration", visibility: { - snapshot: () => { - const connection = relay.snapshot(); - const state = membership.snapshot(); - if (canManageMembership(state)) return true; - if ( - connection.status === "error" || - connection.status === "disconnected" || - state.error - ) - return "error"; - if ( - state.status === "idle" || - state.status === "loading" || - state.refreshing - ) - return "pending"; - return false; - }, + snapshot: manageable, subscribe: membership.subscribe, ensure: membership.ensure, }, @@ -60,4 +69,26 @@ export const apply: PluginModule["apply"] = (ctx) => { /> ), }); + // Owners and admins set the welcome and starter channels. The card itself + // keeps the welcome form to relays that support it. The message editor + // shows pasted links through the conversation's link renderers, as the + // card does. + ctx.settingsCards.register({ + id: "welcome", + title: "Welcome", + section: "administration", + visibility: { + snapshot: manageable, + subscribe: membership.subscribe, + ensure: membership.ensure, + }, + component: ({ active }) => ( + + ), + }); }; diff --git a/src/bundled/moderation/registration.test.ts b/src/bundled/moderation/registration.test.ts index ef7633e9d7..d9d0c850fb 100644 --- a/src/bundled/moderation/registration.test.ts +++ b/src/bundled/moderation/registration.test.ts @@ -8,14 +8,20 @@ import { SettingsCardsService } from "../../features/settings/service"; import { createAdminAccess } from "../admin-access"; import type { CommunityReader } from "../../features/communities/service"; import type { RelayStaffBackend } from "../../features/relay-staff/contract"; +import { communityRequest } from "../../features/communities/api"; import { apply, inject } from "./index"; import manifest from "./manifest.json"; +vi.mock("../../features/communities/api", async (importOriginal) => ({ + ...(await importOriginal()), + communityRequest: vi.fn(async () => ({ policy: null })), +})); + const relayKey = "f".repeat(64); const owner = "0".repeat(64); -/** A ready connection whose relay-signed roster names the viewer an owner. */ -function relay(): RelayData { +/** A ready connection whose relay-signed roster gives the viewer `role`. */ +function relay(role = "owner"): RelayData { const session = { relayAuthor: relayKey, subscribeLive: () => () => {}, @@ -27,7 +33,7 @@ function relay(): RelayData { created_at: 1, content: "", sig: "", - tags: [["-"], ["member", owner, "owner"]], + tags: [["-"], ["member", owner, role]], }, ], }; @@ -58,6 +64,14 @@ it("registers the Membership card under the key the community rail opens", async }); const data = relay(); root.provide("relay", data); + // The Welcome card names the community in its default title. + root.provide("communityReader", { + snapshot: () => ({ memberships: [] }), + subscribe: () => () => {}, + } as unknown as CommunityReader); + // Its message editor shows pasted links through the conversation's + // link renderers. + root.provide("conversation", {} as never); const access = createAdminAccess( data, { @@ -97,3 +111,71 @@ it("registers the Membership card under the key the community rail opens", async await root.fiber.dispose(); access.dispose(); }); + +it.each([ + ["owner", true], + ["member", false], +])( + "shows the Welcome card to a community %s on any relay: %s", + async (role, visible) => { + // Starter channels need no relay support; the card hides only the + // welcome form when the relay lacks it. + vi.mocked(communityRequest).mockResolvedValue({ + policy: null, + landingChannels: false, + }); + const root = new Context(); + provideNavigation(root); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const data = relay(role); + root.provide("relay", data); + root.provide("communityReader", { + snapshot: () => ({ memberships: [] }), + subscribe: () => () => {}, + } as unknown as CommunityReader); + root.provide("conversation", {} as never); + const access = createAdminAccess( + data, + { + snapshot: () => ({ + status: "ready", + selected: "https://primary.example", + viewer: owner, + }), + subscribe: () => () => {}, + } as unknown as CommunityReader, + { available: false } as RelayStaffBackend, + ); + root.provide("adminAccess", access); + const cards = new SettingsCardsService(root); + const releaseVisibility = cards.retainVisibility(); + const fiber = root + .extend({ pluginOwner: { id: manifest.id, revision: "one" } }) + .plugin({ name: manifest.id, inject, apply }); + await fiber.await(); + await flush(); + + // The roster settles both cards; a member sees neither. + await vi.waitFor(() => + expect(cards.visibility(MEMBERSHIP_SECTION)).toBe(visible), + ); + expect(cards.visibility("buzz.moderation/welcome")).toBe(visible); + const card = cards + .snapshot() + .find((entry) => entry.key === "buzz.moderation/welcome"); + if (visible) + expect(card).toMatchObject({ + title: "Welcome", + section: "administration", + }); + else expect(card).toBeUndefined(); + + await fiber.dispose(); + releaseVisibility(); + await root.fiber.dispose(); + access.dispose(); + }, +); diff --git a/src/features/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx index 848f1b2dc3..782219d8ae 100644 --- a/src/features/channel-navigation/ChannelNavigationState.test.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx @@ -196,7 +196,7 @@ it.each([true, false])( await screen.findByRole("dialog", { name: "Archive channel: general" }), ).toHaveAccessibleDescription( autoJoin - ? `${archive} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn off Starter channel for new members in Settings first.` + ? `${archive} It’s a starter channel, so new members will be added to it again once it’s unarchived. To stop that, turn it off in Settings → Community admin → Welcome first.` : archive, ); }, diff --git a/src/features/communities/WelcomeCard.module.css b/src/features/communities/WelcomeCard.module.css new file mode 100644 index 0000000000..c66619e2c1 --- /dev/null +++ b/src/features/communities/WelcomeCard.module.css @@ -0,0 +1,55 @@ +@reference "../../shared/styles/globals.css"; + +/* Floats over the end of the history, as a channel's join notice does; the + page extends the history's end so its last messages scroll clear. */ +.pinned { + position: absolute; + inset-inline: 0; + inset-block-end: 0; + z-index: 10; + display: flex; + justify-content: center; + padding: var(--space-3); + /* Unlike a preview, this channel has a composer, and "X is typing" floats a + caption pill (padding space-1, raised space-half) just above it, in this + same corner (TypingIndicator.module.css). Leave that lane below the card, + and the reported height carries it to the history's end. */ + padding-block-end: calc( + var(--space-3) + + var(--space-half) + + 2 * + var(--space-1) + + var(--text-caption) * + var(--text-caption--line-height) + ); + pointer-events: none; +} +.card { + @apply floating-surface; + /* Content padding: keep nested corners concentric (DESIGN.md, Floating surfaces). */ + --floating-inset: var(--space-3); + pointer-events: auto; + width: min(100%, 40rem); + padding: var(--floating-inset) var(--space-4) var(--space-4); + color: var(--text-standard); + text-align: start; +} +.header { + display: flex; + align-items: center; + gap: var(--space-2); +} +.title { + flex: 1; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} +/* A long welcome scrolls within the card instead of covering the history. */ +.body { + max-block-size: min(12rem, 30vh); + overflow-y: auto; + margin-block-start: var(--space-1); + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/src/features/communities/WelcomeCard.test.tsx b/src/features/communities/WelcomeCard.test.tsx new file mode 100644 index 0000000000..a4e066c8bd --- /dev/null +++ b/src/features/communities/WelcomeCard.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { profileTarget } from "../profiles/target"; +import type { RelaySession } from "../relay/session"; +import type { CommunityReader } from "./service"; +import { + CommunityWelcomeCard, + DEFAULT_WELCOME_MESSAGE, + WelcomeCard, + WelcomeMessage, +} from "./WelcomeCard"; + +beforeEach(() => { + // jsdom has no ResizeObserver; the card measures whether its body scrolls. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const channel = "11111111-1111-4111-8111-111111111111"; +const scope = `https://primary.example:${"0".repeat(64)}`; +const person = "a".repeat(64); +const joined = { status: "ready", channels: [] }; +const session = { + viewer: "0".repeat(64), + media: () => undefined, + channels: { + list: () => joined, + subscribeList: () => () => {}, + }, +} as unknown as RelaySession; + +function welcome( + message: string | undefined, + options: Partial[0]> = {}, +) { + return ( + + false} + {...options} + /> + + ); +} + +it("titles the card and shows the default message", () => { + render(welcome(undefined)); + const card = screen.getByRole("region", { name: "Welcome to Primary" }); + expect(card).toHaveTextContent(DEFAULT_WELCOME_MESSAGE); +}); + +it("renders a custom message as Markdown, never as HTML", () => { + render(welcome("**Hi** all\nWelcome")); + const card = screen.getByRole("region", { name: "Welcome to Primary" }); + expect(card.querySelector("strong")).toHaveTextContent("Hi"); + expect(card.querySelector("b")).toBeNull(); + // A single newline is a line break, as in messages. + expect(card.querySelector("br")).not.toBeNull(); +}); + +it("opens an identity link's profile from the timeline without notifying", async () => { + const target = profileTarget(person); + const onOpenLink = vi.fn(() => true); + render( + welcome(`Ask [@Ana](${target}) anything.`, { + onOpenLink, + canOpenLink: () => true, + }), + ); + await userEvent.click( + screen.getByRole("button", { name: "View Ana profile" }), + ); + expect(onOpenLink).toHaveBeenCalledWith(target); +}); + +it("shows an identity link as an inert chip when not interactive, as the editor's chips do", () => { + const target = profileTarget(person); + const view = render( + welcome(`Ask [@Ana](${target}) anything.`, { interactive: false }), + ); + expect(screen.queryByRole("button")).toBeNull(); + expect( + view.container.querySelector(`[data-profile-target="${target}"]`), + ).toHaveTextContent("Ana"); + expect(view.container.textContent).not.toContain(target); +}); + +it("titles the default with the community the viewer saved, else a custom title", () => { + // A stable snapshot, as the real reader keeps it between changes. + const saved = { + memberships: [{ id: "https://primary.example", name: "Primary" }], + }; + const communities = { + snapshot: () => saved, + subscribe: () => () => {}, + } as unknown as CommunityReader; + const { rerender } = render( + + Body + , + ); + expect( + screen.getByRole("region", { name: "Welcome to Primary" }), + ).toBeVisible(); + rerender( + + Body + , + ); + expect(screen.getByRole("region", { name: "Hello, newcomer" })).toBeVisible(); + rerender( + + Body + , + ); + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); +}); + +it("welcomes a community known only by its address without naming it", () => { + const reader = (membership: Record) => { + const saved = { + memberships: [{ id: "https://primary.example", ...membership }], + }; + return { + snapshot: () => saved, + subscribe: () => () => {}, + } as unknown as CommunityReader; + }; + const { rerender } = render( + + Body + , + ); + // Joined without a published name, the membership holds only the host. + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); + rerender( + + Body + , + ); + expect( + screen.getByRole("region", { name: "Welcome to Primary" }), + ).toBeVisible(); + rerender( + + Body + , + ); + expect(screen.getByRole("region", { name: "Welcome to Home" })).toBeVisible(); +}); diff --git a/src/features/communities/WelcomeCard.tsx b/src/features/communities/WelcomeCard.tsx new file mode 100644 index 0000000000..b8b78ccbb2 --- /dev/null +++ b/src/features/communities/WelcomeCard.tsx @@ -0,0 +1,223 @@ +import { + useId, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { ConversationExtensions } from "../conversation/contracts"; +import { MessageMarkdown } from "../messages/MessageMarkdown"; +import { useReferenceDirectory } from "../messages/ReferenceText"; +import { splitPartition } from "../relay/partition"; +import type { RelaySession } from "../relay/session"; +import { communityPresentation } from "./community-icon"; +import { communityDestination } from "./destination"; +import type { CommunityReader } from "./service"; +import { Button } from "../../shared/design-system/ui/Button"; +import styles from "./WelcomeCard.module.css"; + +/** Shown when the community has not set its own welcome message. */ +export const DEFAULT_WELCOME_MESSAGE = + "This is a place to connect with the community and keep up with what’s happening. Explore the channels to find conversations that interest you."; + +/** Shown when the community has not set its own welcome title. */ +export function defaultWelcomeTitle(communityName?: string | undefined) { + return communityName ? `Welcome to ${communityName}` : "Welcome"; +} + +const noCommunities = () => () => {}; +const noSnapshot = () => undefined; + +/** The default title for the community of the relay session `scope`. Only a + * configured name counts, this viewer's own or the relay's published one; a + * community known only by its address is welcomed without a name. */ +export function useDefaultWelcomeTitle( + communities: CommunityReader | undefined, + scope: string, +) { + const snapshot = useSyncExternalStore( + communities?.subscribe ?? noCommunities, + communities?.snapshot ?? noSnapshot, + ); + const origin = splitPartition(scope)?.communityOrigin; + let name: string | undefined; + if (origin && origin !== "offline") + try { + const presented = communityPresentation(origin, snapshot?.memberships); + // Without a configured name, the presentation falls back to the host. + if (presented.name !== communityDestination(origin).name) + name = presented.name; + } catch { + // An unscoped reader has no community presentation to show. + } + return defaultWelcomeTitle(name); +} + +/** The welcome channel's introduction, never a message. A long message + * scrolls within the card, which is then reachable by keyboard. */ +export function WelcomeCard({ + title, + children, + level = 2, + onDismiss, +}: { + title: string; + /** The rendered message, usually a `WelcomeMessage`. */ + children: ReactNode; + level?: 2 | 3 | 4; + /** Offers Dismiss; `focused` says whether focus was in the card. */ + onDismiss?: ((focused: boolean) => void) | undefined; +}) { + const id = useId(); + const Heading = `h${level}` as "h2" | "h3" | "h4"; + const card = useRef(null); + const body = useRef(null); + const content = useRef(null); + const [scrolls, setScrolls] = useState(false); + useLayoutEffect(() => { + const scroller = body.current; + if (!scroller || !content.current) return; + const measure = () => + setScrolls(scroller.scrollHeight > scroller.clientHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(scroller); + observer.observe(content.current); + return () => observer.disconnect(); + }, []); + return ( +
+
+ + {title} + + {onDismiss && ( + + )} +
+
+
{children}
+
+
+ ); +} + +/** + * The welcome message: Markdown source rendered as a message body is, with + * its `buzz://` channel and message links and `nostr:npub` identity links. + * Identity links name people without notifying them. A non-interactive + * render, such as the editor's link chips, shows the same references without + * their actions. + */ +export function WelcomeMessage({ + message, + session, + scope, + channelId, + extensions, + onOpenLink, + canOpenLink, + interactive = true, +}: { + /** Markdown source; `undefined` shows the default. */ + message?: string | undefined; + session: RelaySession; + scope: string; + /** Scopes identity names to this channel's members. */ + channelId: string; + extensions?: ConversationExtensions | undefined; + onOpenLink(url: string): boolean; + canOpenLink?: ((target: string) => boolean) | undefined; + interactive?: boolean; +}) { + const directory = useReferenceDirectory(session); + return ( + + ); +} + +/** The welcome card, titled by default with the community's configured + * name (see {@link useDefaultWelcomeTitle}). */ +export function CommunityWelcomeCard({ + communities, + scope, + title, + children, + onDismiss, +}: { + communities?: CommunityReader | undefined; + /** The relay session's partition. */ + scope: string; + /** Custom title; absent shows the default. */ + title?: string | undefined; + children: ReactNode; + onDismiss?: ((focused: boolean) => void) | undefined; +}) { + const defaultTitle = useDefaultWelcomeTitle(communities, scope); + return ( + + {children} + + ); +} + +/** The welcome card pinned over the end of the welcome channel's history, + * above the composer, as a channel's join notice is. It reports its height so + * the history can leave the last messages clear of it, and never takes focus. */ +export function PinnedWelcome({ + onHeightChange, + ...card +}: Parameters[0] & { + onDismiss(focused: boolean): void; + onHeightChange(height: number): void; +}) { + const pinned = useRef(null); + useLayoutEffect(() => { + const element = pinned.current; + if (!element) return; + const measure = () => + onHeightChange(Math.ceil(element.getBoundingClientRect().height)); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [onHeightChange]); + return ( +
+ +
+ ); +} diff --git a/src/features/communities/api.ts b/src/features/communities/api.ts index c67cb0f5ef..b3eec77d6b 100644 --- a/src/features/communities/api.ts +++ b/src/features/communities/api.ts @@ -15,6 +15,10 @@ export type CommunityInfo = { name?: string; icon?: string; policy: JoinPolicy | null; + /** The relay advertises `buzz-landing-channel`: admins can set a welcome channel. */ + landingChannels?: boolean; + /** The designated welcome channel, read live from NIP-11. */ + landingChannel?: string; }; export async function communityRequest( id: string, diff --git a/src/features/communities/device-state.test.ts b/src/features/communities/device-state.test.ts index c3e3231c51..e38b7e74e7 100644 --- a/src/features/communities/device-state.test.ts +++ b/src/features/communities/device-state.test.ts @@ -86,7 +86,18 @@ it("forgets every store partitioned to the left community and viewer, and nothin localStorage.setItem(receipt(`${kept}:${viewer}`), "1"); localStorage.setItem(follows(scope), "[]"); localStorage.setItem(follows(`${kept}:${viewer}`), "[]"); + // The welcome record is the one store scoped by origin alone. + writeView(origin, "welcome-first-open", 1_788_810_000); + writeView(origin, "welcome-dismissed:general", true); + writeView(origin, "unrelated", "kept"); + writeView(kept, "welcome-first-open", 1_788_810_000); expect(await purgeCommunityDeviceState(origin, viewer)).toEqual([]); + expect(readView(origin, "welcome-first-open", undefined)).toBeUndefined(); + expect( + readView(origin, "welcome-dismissed:general", undefined), + ).toBeUndefined(); + expect(readView(origin, "unrelated", undefined)).toBe("kept"); + expect(readView(kept, "welcome-first-open", undefined)).toBe(1_788_810_000); expect( localStorage.getItem(localCommunityNameKey(origin, viewer)), ).toBeNull(); @@ -119,7 +130,8 @@ it("clears the remaining stores when one is unavailable and reports each failure writeView(scope, "draft:general", "unsent"); localStorage.setItem(reactions(scope), '[{"emoji":"🎉","count":1,"used":1}]'); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - // Key enumeration fails, which the view and channel-setup sweeps rely on. + // Key enumeration fails, which the view, welcome and channel-setup sweeps + // rely on. const denied = new Error("denied"); vi.spyOn(Storage.prototype, "key").mockImplementation(() => { throw denied; @@ -127,6 +139,7 @@ it("clears the remaining stores when one is unavailable and reports each failure const failures = await purgeCommunityDeviceState(origin, viewer); expect(failures).toEqual([ { store: "view state", error: denied }, + { store: "welcome record", error: denied }, { store: "channel setups", error: denied }, ]); expect(localStorage.getItem(reactions(scope))).toBeNull(); @@ -134,6 +147,7 @@ it("clears the remaining stores when one is unavailable and reports each failure // naming the store and the community it belongs to. expect(warn.mock.calls).toEqual([ [`Couldn't clear view state for ${origin} on this device`, denied], + [`Couldn't clear welcome record for ${origin} on this device`, denied], [`Couldn't clear channel setups for ${origin} on this device`, denied], ]); }); diff --git a/src/features/communities/device-state.ts b/src/features/communities/device-state.ts index 2a1d2207a4..8c0d5c4d14 100644 --- a/src/features/communities/device-state.ts +++ b/src/features/communities/device-state.ts @@ -8,6 +8,7 @@ import { forgetThreadFollows } from "../relay/thread-follows"; import { clearViewScope } from "../../shared/view-state"; import { saveLocalCommunityName } from "./names"; import { saveRepositoriesFolder } from "./repositories"; +import { forgetWelcome } from "./welcome-pin"; /** One piece of a left community's device state that could not be cleared. */ export type PurgeFailure = { store: string; error: unknown }; @@ -27,7 +28,9 @@ function purgeFailure( /** Forgets what this device keeps for one community and viewer: view intent * (selected channel, drafts, scroll and sidebar state), channel heads, the * read-state journal, the outbox and smaller preference stores. Every store is - * partitioned by `origin:viewer`, so nothing here can touch another community. + * partitioned by `origin:viewer`, so nothing here can touch another community, + * except the welcome record (first open and dismissals), which the device + * keeps per origin for all of its identities and forgets here with the rest. * Each store is cleared independently: an unavailable one does not keep the * others, and the caller has already dropped the membership. Every failure is * logged and returned, named by store, so the caller can say some saved data @@ -55,6 +58,7 @@ export async function purgeCommunityDeviceState( saveRepositoriesFolder(origin, viewer, ""); }); await attempt("view state", () => clearViewScope(scope)); + await attempt("welcome record", () => forgetWelcome(origin)); await attempt("channel setups", () => forgetChannelSetups(scope)); await attempt("quick reactions", () => forgetQuickReactions(scope)); await attempt("thread follows", () => forgetThreadFollows(scope)); diff --git a/src/features/communities/native-api.test.ts b/src/features/communities/native-api.test.ts index e9fe0fe5ec..03c653c5ef 100644 --- a/src/features/communities/native-api.test.ts +++ b/src/features/communities/native-api.test.ts @@ -441,3 +441,25 @@ it.each([ expect(info.membershipRequired).toBe(nips.includes(43)); }, ); + +it("projects welcome-channel support and designation from NIP-11", async () => { + const landing = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11"; + vi.mocked(invoke).mockImplementation(async (_command, args) => ({ + status: 200, + headers: {}, + body: JSON.stringify( + (args as { path: string }).path === "/" + ? { + self: key.pubkey, + supported_extensions: ["buzz-landing-channel"], + landing_channel: landing, + } + : { policy: null }, + ), + })); + const info = await communityRequest(community, "info"); + expect(info).toMatchObject({ + landingChannels: true, + landingChannel: landing, + }); +}); diff --git a/src/features/communities/native-api.ts b/src/features/communities/native-api.ts index 405855a184..89f69e1816 100644 --- a/src/features/communities/native-api.ts +++ b/src/features/communities/native-api.ts @@ -12,6 +12,7 @@ import { leaveRefusal, leaveRequestTemplate } from "./leave-protocol"; import { adminReason, memberCommand } from "./admin-protocol"; import { readRelayLibrary } from "../agents/relay-library"; import { eventDto } from "../relay/events"; +import { landingInfo } from "../relay/landing"; import { readApiFailure } from "../relay/http-admission"; import { admitSignedRequest } from "../relay/transport"; import type { PersonalProfile } from "./service"; @@ -69,6 +70,7 @@ export async function nativeCommunityRequest( response.status === 404 ? null : readJoinPolicy(await readResponse(response)), + ...landingInfo(info), }; } if (route === "session") { diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts index 1a7348afba..cb5d09c5c7 100644 --- a/src/features/communities/service.test.ts +++ b/src/features/communities/service.test.ts @@ -625,7 +625,7 @@ it("leave still forgets the community when a store will not clear, and logs and '[{"emoji":"🎉","count":1,"used":1}]', ); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - // Key enumeration is what the view and channel-setup sweeps rely on. + // Key enumeration is what the view, welcome and channel-setup sweeps rely on. const denied = new Error("denied"); vi.spyOn(localStorage, "key").mockImplementation(() => { throw denied; @@ -639,12 +639,17 @@ it("leave still forgets the community when a store will not clear, and logs and ).toBeNull(); expect(failures).toEqual([ { store: "view state", error: denied }, + { store: "welcome record", error: denied }, { store: "channel setups", error: denied }, ]); expect(warn).toHaveBeenCalledWith( `Couldn't clear view state for ${origin} on this device`, denied, ); + expect(warn).toHaveBeenCalledWith( + `Couldn't clear welcome record for ${origin} on this device`, + denied, + ); expect(warn).toHaveBeenCalledWith( `Couldn't clear channel setups for ${origin} on this device`, denied, diff --git a/src/features/communities/welcome-pin.test.tsx b/src/features/communities/welcome-pin.test.tsx new file mode 100644 index 0000000000..f023b762e9 --- /dev/null +++ b/src/features/communities/welcome-pin.test.tsx @@ -0,0 +1,285 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { SnapshotRead } from "../agents/agent-config-artifacts"; +import type { ReadFilter, RelayEvent } from "../relay/events"; +import { matchesEvent } from "../relay/projection"; +import { readView, viewRevision, writeView } from "../../shared/view-state"; +import { + recordWelcomeFirstOpen, + usePinnedWelcome, + WELCOME_EARLIER_ACTIVITY_KEY, + WELCOME_FIRST_OPEN_KEY, + welcomeDismissedKey, +} from "./welcome-pin"; +import { PinnedWelcome } from "./WelcomeCard"; + +const origin = "https://primary.example"; +const me = "a".repeat(64); +const other = "b".repeat(64); +const scope = `${origin}:${me}`; +const channel = "11111111-1111-4111-8111-111111111111"; +const moved = "22222222-2222-4222-8222-222222222222"; +/** When this device first opened the community. */ +const T = 1_788_810_000; + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +const authored = (pubkey: string, createdAt: number) => + ({ + id: createdAt.toString(16).padStart(64, "0"), + pubkey, + created_at: createdAt, + kind: 9, + content: "hello", + tags: [["h", "general"]], + }) as unknown as RelayEvent; + +/** A relay holding `events`. Each read waits until the test answers it, with + * what the relay would return for its filters, or with a failure. */ +function fakeRelay(events: RelayEvent[] = []) { + const reads: { + filters: readonly ReadFilter[]; + answer(): Promise; + fail(): Promise; + }[] = []; + const snapshotRead: SnapshotRead = (filters) => + new Promise((resolve, reject) => { + reads.push({ + filters, + answer: () => + act(async () => { + resolve( + events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ); + await Promise.resolve(); + }), + fail: () => + act(async () => { + reject(new Error("Relay is disconnected")); + await Promise.resolve(); + }), + }); + }); + return { reads, snapshotRead }; +} + +/** The welcome channel's slot as Messages composes it. */ +function Slot({ + relay, + viewer = me, + channelId = channel, + title, + message = "Say hi", + onRender, +}: { + relay: ReturnType | undefined; + viewer?: string; + channelId?: string; + title?: string; + message?: string; + /** Sees what each render decided, in order. */ + onRender?: (shown: boolean) => void; +}) { + const welcome = usePinnedWelcome({ + session: { viewer, snapshotRead: relay?.snapshotRead }, + scope: `${origin}:${viewer}`, + channelId, + member: true, + }); + onRender?.(welcome.shown); + return welcome.shown ? ( + welcome.dismiss()} + onHeightChange={() => {}} + > +

{message}

+
+ ) : null; +} + +it("records this device's first open once per relay, for every identity", () => { + recordWelcomeFirstOpen(scope, T * 1000 + 999); + recordWelcomeFirstOpen(`${origin}:${other}`, (T + 60) * 1000); + // Scoped by relay alone, and never replaced. + expect(readView(origin, WELCOME_FIRST_OPEN_KEY, undefined)).toBe(T); + expect(viewRevision(scope, WELCOME_FIRST_OPEN_KEY)).toBeNull(); +}); + +it("shows a new member the welcome once the relay has nothing earlier by them", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay(); + render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + // Nothing shows while the answer is pending. + expect(screen.queryByRole("region")).toBeNull(); + await relay.reads[0]?.answer(); + const card = screen.getByRole("region", { name: "Hello, newcomer" }); + expect(card).toHaveTextContent("Say hi"); + expect(card).toContainElement( + screen.getByRole("button", { name: "Dismiss" }), + ); + expect(card).not.toContainElement(document.activeElement as HTMLElement); + expect(readView(scope, WELCOME_EARLIER_ACTIVITY_KEY, undefined)).toBe(false); +}); + +it("never shows the welcome to a member with an older event, and asks only once", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay([authored(me, T - 86_400)]); + const { unmount } = render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + await relay.reads[0]?.answer(); + expect(readView(scope, WELCOME_EARLIER_ACTIVITY_KEY, undefined)).toBe(true); + expect(screen.queryByRole("region")).toBeNull(); + unmount(); + // The answer is kept for this relay and viewer on this device. + render(); + await act(async () => {}); + expect(relay.reads).toHaveLength(1); + expect(screen.queryByRole("region")).toBeNull(); + cleanup(); + // Another identity on this device asks about itself. + render(); + await waitFor(() => expect(relay.reads).toHaveLength(2)); + expect(relay.reads[1]?.filters).toStrictEqual([ + { authors: [other], until: T - 600, limit: 1 }, + ]); +}); + +it("counts an event from the ten minutes before the first open as new, with one kindless read", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay([authored(me, T - 300)]); + render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + // Any kind, in any channel the relay lets this viewer read: no kinds, no #h. + expect(relay.reads[0]?.filters).toStrictEqual([ + { authors: [me], until: T - 600, limit: 1 }, + ]); + await relay.reads[0]?.answer(); + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); +}); + +it("shows a kept answer in the first commit, without asking again", () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + writeView(scope, WELCOME_EARLIER_ACTIVITY_KEY, false); + const relay = fakeRelay(); + const renders: boolean[] = []; + render( renders.push(shown)} />); + expect(renders[0]).toBe(true); + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); + expect(relay.reads).toHaveLength(0); +}); + +it("keeps nothing from a failed read and asks again on the next open, not in the same one", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay(); + const { rerender, unmount } = render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + await relay.reads[0]?.fail(); + expect(screen.queryByRole("region")).toBeNull(); + expect(viewRevision(scope, WELCOME_EARLIER_ACTIVITY_KEY)).toBeNull(); + // Rendering again is not a new open: the same mount never retries. + rerender(); + await act(async () => {}); + expect(relay.reads).toHaveLength(1); + unmount(); + render(); + await waitFor(() => expect(relay.reads).toHaveLength(2)); + await relay.reads[1]?.answer(); + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); +}); + +it("asks nothing without a session or a recorded first open", async () => { + const relay = fakeRelay(); + render(); + await act(async () => {}); + expect(relay.reads).toHaveLength(0); + cleanup(); + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + render(); + await act(async () => {}); + expect(screen.queryByRole("region")).toBeNull(); + expect(viewRevision(scope, WELCOME_EARLIER_ACTIVITY_KEY)).toBeNull(); +}); + +it("stays dismissed on this device after a reload", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay(); + const { unmount } = render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + await relay.reads[0]?.answer(); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("region")).toBeNull(); + // Scoped by relay alone and keyed by the channel. + expect(readView(origin, welcomeDismissedKey(channel), undefined)).toBe(true); + unmount(); + render(); + await act(async () => {}); + expect(screen.queryByRole("region")).toBeNull(); + expect(relay.reads).toHaveLength(1); +}); + +it("stays dismissed when an admin changes the welcome afterwards", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay(); + const { rerender, unmount } = render( + , + ); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + await relay.reads[0]?.answer(); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + rerender(); + await act(async () => {}); + expect(screen.queryByRole("region")).toBeNull(); + unmount(); + render(); + await act(async () => {}); + expect(screen.queryByRole("region")).toBeNull(); + expect(screen.queryByText("Edited")).toBeNull(); +}); + +it("shows the welcome once more in a channel it moves to, and never again where it was dismissed", async () => { + writeView(origin, WELCOME_FIRST_OPEN_KEY, T); + const relay = fakeRelay(); + const { rerender, unmount } = render(); + await waitFor(() => expect(relay.reads).toHaveLength(1)); + await relay.reads[0]?.answer(); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("region")).toBeNull(); + // An admin moves the welcome to another channel: a new welcome, served by + // the kept answer without another read. + rerender(); + expect(screen.getByRole("region", { name: "Welcome" })).toBeVisible(); + expect(relay.reads).toHaveLength(1); + expect( + readView(origin, welcomeDismissedKey(moved), undefined), + ).toBeUndefined(); + // Moved back, it stays dismissed there, on this and the next open. + rerender(); + expect(screen.queryByRole("region")).toBeNull(); + unmount(); + render(); + await act(async () => {}); + expect(screen.queryByRole("region")).toBeNull(); + expect(relay.reads).toHaveLength(1); +}); diff --git a/src/features/communities/welcome-pin.ts b/src/features/communities/welcome-pin.ts new file mode 100644 index 0000000000..21ca153029 --- /dev/null +++ b/src/features/communities/welcome-pin.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import type { SnapshotRead } from "../agents/agent-config-artifacts"; +import { earlierActivityFilter } from "../relay/earlier-activity"; +import { splitPartition } from "../relay/partition"; +import { + clearViewScope, + readView, + replaceView, + subscribeView, + writeView, +} from "../../shared/view-state"; + +/** A newcomer's own events from the minutes before this device first opened + * the community, such as a profile set up while joining, still count as new. */ +export const WELCOME_GRACE_SECONDS = 600; +/** View key, scoped by relay alone (every identity on this device): unix + * seconds when this device first opened the community in Messages. */ +export const WELCOME_FIRST_OPEN_KEY = "welcome-first-open"; +/** View key, scoped by relay and viewer: whether the relay had an event by the + * viewer from before {@link WELCOME_FIRST_OPEN_KEY} less the grace period. */ +export const WELCOME_EARLIER_ACTIVITY_KEY = "welcome-earlier-activity"; +/** View key, scoped by relay alone: this welcome channel's card was dismissed. + * A welcome moved to another channel is a new welcome, with its own key. */ +export const welcomeDismissedKey = (channelId: string) => + `welcome-dismissed:${channelId}`; +/** Every welcome key scoped by relay alone starts with this. */ +const WELCOME_KEY_PREFIX = "welcome-"; + +const origin = (scope: string) => splitPartition(scope)?.communityOrigin; +const unscoped = () => () => {}; + +/** Records when this device first opened the community of the relay session + * `scope`, unless it has before. An existing install records its first open + * after upgrading. */ +export function recordWelcomeFirstOpen(scope: string, now = Date.now()) { + const relay = origin(scope); + if (relay) + replaceView(relay, WELCOME_FIRST_OPEN_KEY, null, Math.floor(now / 1000)); +} + +/** Forgets the welcome state this device keeps for the community at `origin` + * across its identities: the first open and every dismissal. A later rejoin + * records a fresh first open; the relay's answer still classes a member with + * older events as existing. */ +export function forgetWelcome(origin: string) { + clearViewScope(origin, WELCOME_KEY_PREFIX); +} + +/** + * Whether the welcome channel `channelId` shows its pinned welcome to this + * viewer, who must be a member of it (`member`): only while this device has a + * first-open time for the community, the card has not been dismissed here, and + * the relay has no event by the viewer from before that time less + * {@link WELCOME_GRACE_SECONDS}. One read answers that, once per relay and + * viewer on this device. Nothing shows while it is pending, and a failed read + * is not an answer: nothing is kept and the next visit asks again. + */ +export function usePinnedWelcome({ + session, + scope, + channelId, + member, +}: { + session: Readonly<{ + viewer?: string | undefined; + snapshotRead?: SnapshotRead | undefined; + }>; + /** The relay session's partition. */ + scope: string; + channelId: string | undefined; + member: boolean; +}) { + const relay = origin(scope); + const { viewer, snapshotRead: read } = session; + const dismissKey = channelId && welcomeDismissedKey(channelId); + const dismissed = useSyncExternalStore( + useCallback( + (listener: () => void) => + relay ? subscribeView(relay, listener) : unscoped(), + [relay], + ), + // Messages renders on every list change; read storage only when it counts. + () => + member && + !!relay && + !!dismissKey && + readView(relay, dismissKey, false) === true, + ); + // Hides at once even if storage cannot keep the dismissal. + const [closed, setClosed] = useState(); + const asking = + member && !!channelId && !dismissed && closed !== channelId && !!relay; + // A kept answer is read during render, so a card it allows is in the first + // commit with the channel, not one commit after it painted. + const kept = useSyncExternalStore( + useCallback( + (listener: () => void) => subscribeView(scope, listener), + [scope], + ), + () => { + if (!asking) return undefined; + const value = readView( + scope, + WELCOME_EARLIER_ACTIVITY_KEY, + undefined, + ); + return typeof value === "boolean" ? value : undefined; + }, + ); + // The relay's answer when storage could not keep it. + const [answer, setAnswer] = useState<{ scope: string; earlier: boolean }>(); + const earlier = + kept ?? (answer?.scope === scope ? answer.earlier : undefined); + const answered = earlier !== undefined; + useEffect(() => { + if (!asking || answered || !relay || !viewer) return; + const firstOpen = readView(relay, WELCOME_FIRST_OPEN_KEY, null); + if ( + !read || + typeof firstOpen !== "number" || + !Number.isSafeInteger(firstOpen) + ) + return; + const controller = new AbortController(); + read([earlierActivityFilter(viewer, firstOpen - WELCOME_GRACE_SECONDS)], { + signal: controller.signal, + }).then( + (events) => { + if (controller.signal.aborted) return; + const earlier = events.length > 0; + writeView(scope, WELCOME_EARLIER_ACTIVITY_KEY, earlier); + setAnswer({ scope, earlier }); + }, + () => { + // Offline, refused or timed out proves nothing about this member. + }, + ); + return () => controller.abort(); + }, [asking, answered, relay, scope, viewer, read]); + const dismiss = useCallback(() => { + if (!relay || !dismissKey || !channelId) return; + setClosed(channelId); + writeView(relay, dismissKey, true); + }, [relay, dismissKey, channelId]); + return { + shown: asking && earlier === false, + dismiss, + }; +} diff --git a/src/features/messages/EditableInput.tsx b/src/features/messages/EditableInput.tsx index b85c76342d..9b475fff83 100644 --- a/src/features/messages/EditableInput.tsx +++ b/src/features/messages/EditableInput.tsx @@ -389,6 +389,12 @@ export type EditableInputProps = Omit< /** Vet a pasted identity link as a notification recipient: the recipient under * its current name, or null to keep the display-only chip. */ acceptRecipient?(pubkey: string): MentionRecipient | null; + /** The source to insert for pasted text that replaces `start`–`end` of the + * draft text `source`. Raw pastes, code and links keep the text as copied. */ + pastedText?( + text: string, + at: { source: string; start: number; end: number }, + ): string; }; /** ProseMirror owns native editing, composition, selection and a single history. @@ -406,6 +412,7 @@ export function EditableInput({ onEditLink, onPasteTooLong, acceptRecipient, + pastedText, ...events }: EditableInputProps & { draft: MentionDraft; @@ -423,6 +430,7 @@ export function EditableInput({ onEditLink, onPasteTooLong, acceptRecipient, + pastedText, disabled, placeholder, autocorrect, @@ -436,6 +444,7 @@ export function EditableInput({ onEditLink, onPasteTooLong, acceptRecipient, + pastedText, disabled, placeholder, autocorrect, @@ -2182,6 +2191,22 @@ export function EditableInput({ externalLinkMarkdown(html, hasPlain ? plain : undefined))) || plain || ""; + const marks = + editor.state.storedMarks ?? editor.state.selection.$from.marks(); + if ( + !code && + current.current.pastedText && + !marks.some( + ({ type }) => + type === composerSchema.marks.code || + type === composerSchema.marks.link, + ) + ) + text = current.current.pastedText(text, { + source: source.draft.text, + start, + end, + }); if (!text) return true; // A bare URL pasted over selected text links the selection, as other // editors do. editLink() owns the guards (code, resources, unsafe diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 4df15ebd59..a0e5939187 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -1100,6 +1100,26 @@ it.each<[string, RenderOptions]>([ expect(view.container.textContent).toBe("Mic"); }); +it("shows an explicit profile identity as an inert chip in a preview", () => { + const target = profileTarget(mic); + const view = renderDom( + , + ); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByRole("link")).toBeNull(); + // The chip a message would show, without its profile action. + const chip = view.container.querySelector("[data-mention-kind]"); + expect(chip?.tagName).toBe("SPAN"); + expect(chip).toHaveAttribute("data-profile-target", target); + expect(chip).toHaveTextContent("Mic"); + expect(view.container.textContent).not.toContain(target); +}); + it.each([ "nostr:npub1invalid", "nostr:nsec1invalid", diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index 44c99f8a68..4ae065d9ba 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -52,7 +52,7 @@ it("independently unloads panels without removing the page or shared data", asyn sync: emptySync(), }), }); - // Channels hands community membership to its page for auto-join settings. + // Channels hands community membership to its page for the first-channel setup. root.provide("adminAccess", { membership: undefined }); // GitHub reads through gh where the host can run it; here it cannot. root.provide("host", {}); diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts index 6b4c8fe154..c0a4190ab1 100644 --- a/src/features/relay/channel-lifecycle-protocol.ts +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -1,6 +1,7 @@ import type { EventTemplate } from "nostr-tools"; import type { ChannelSummary } from "./contracts.ts"; import { newer, type RelayEvent } from "./events.ts"; +import { validWelcomeCommand, validWelcomeTitleCommand } from "./landing.ts"; export type ChannelLifecycleAction = | "archive" @@ -57,6 +58,49 @@ export function lifecycleTemplate( return event; } +/** The welcome's two parts; `""` restores the default for that part. */ +export type WelcomeCommand = Readonly<{ message: string; title: string }>; + +/** + * Makes a channel the community's welcome channel with its full welcome + * state. The relay accepts a repeat. + */ +export function landingTemplate(channelId: string, welcome: WelcomeCommand) { + const event = { + kind: 9002, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["h", lifecycleChannelId(channelId)], + ["landing", "true"], + ["welcome", welcome.message], + ["welcome_title", welcome.title], + ], + }; + validateLifecycleTemplate(event); + return event; +} + +/** The full-state form: message and title, each `""` for its default. */ +function landingCommand(tags: string[][]) { + const value = (index: number, name: string) => { + const tag = tags[index]; + return tag?.length === 2 && tag[0] === name && typeof tag[1] === "string" + ? tag[1] + : undefined; + }; + const message = value(2, "welcome"); + const title = value(3, "welcome_title"); + return ( + tags.length === 4 && + JSON.stringify(tags[1]) === JSON.stringify(["landing", "true"]) && + message !== undefined && + validWelcomeCommand(message) && + title !== undefined && + validWelcomeTitleCommand(title) + ); +} + /** The host never accepts generic metadata edits or other-member removal. */ export function validateLifecycleTemplate(event: EventTemplate): void { if ( @@ -66,12 +110,22 @@ export function validateLifecycleTemplate(event: EventTemplate): void { !Number.isSafeInteger(event.created_at) || event.created_at < 0 || !Array.isArray(event.tags) || - event.tags.length !== (event.kind === 9002 ? 2 : 1) || + !( + event.tags.length === (event.kind === 9002 ? 2 : 1) || + (event.kind === 9002 && event.tags.length === 4) + ) || event.tags[0]?.length !== 2 || event.tags[0]?.[0] !== "h" ) throw new Error("Invalid channel lifecycle command"); lifecycleChannelId(event.tags[0]?.[1] ?? ""); + if (event.kind === 9002 && event.tags.length > 2) { + if (!landingCommand(event.tags)) + throw new Error( + "Only the welcome channel and its welcome may be set here", + ); + return; + } if ( event.kind === 9002 && !["archived", "auto_join"].some((name) => diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 9d0fed26e4..2ec6132567 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -20,6 +20,7 @@ import { ChannelLifecycleUnconfirmed, } from "./channel-lifecycle"; import { + landingTemplate, lifecycleSettings, lifecycleTemplate, validateLifecycleTemplate, @@ -135,6 +136,7 @@ function harness(role = "owner", type = "stream", owners = 1) { }); const removed = vi.fn(); const acceptDiscovery = vi.fn(); + const acceptDirectory = vi.fn(); const owner = createChannelLifecycle({ reader: { read }, writer: { sign, publish }, @@ -143,6 +145,7 @@ function harness(role = "owner", type = "stream", owners = 1) { canAccess: () => active, removed, acceptDiscovery, + acceptDirectory, }); return { owner, @@ -151,6 +154,7 @@ function harness(role = "owner", type = "stream", owners = 1) { publish, removed, acceptDiscovery, + acceptDirectory, record, roles, metadata, @@ -2199,6 +2203,49 @@ describe("auto-join switch", () => { }, ); + it("flags a channel the viewer has not joined, keeping it out of discovery", async () => { + // Community owners and admins need not belong to the channel. + const h = publicStream(); + h.deny(); + try { + await h.owner.capability.run("auto-join-on", id); + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({ + consistency: "strong", + "#d": [id], + }); + expect(h.acceptDiscovery).not.toHaveBeenCalled(); + // The browsing directory lists it, so it takes the confirmed flag. + expect(h.acceptDirectory).toHaveBeenCalledOnce(); + expect( + h.acceptDirectory.mock.calls[0]?.[0]?.[0]?.tags.some( + ([key]: string[]) => key === "auto_join", + ), + ).toBe(true); + } finally { + h.owner.dispose(); + } + }); + + it("hands a strong metadata read to the directory, and to discovery only for a channel the viewer is in", async () => { + const h = publicStream(); + try { + h.setEvents([h.metadata(true), ...h.getEvents().slice(1)]); + // A check of a starter step reads its flag from the same read. + await expect(h.owner.capability.readLanding(id)).resolves.toMatchObject({ + autoJoin: true, + }); + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + expect(h.acceptDirectory).toHaveBeenCalledOnce(); + h.deny(); + await h.owner.capability.readLanding(id); + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + expect(h.acceptDirectory).toHaveBeenCalledTimes(2); + } finally { + h.owner.dispose(); + } + }); + it("surfaces the relay's refusal and keeps an unconfirmed flag uncertain", async () => { const h = publicStream(); try { @@ -2224,6 +2271,366 @@ describe("auto-join switch", () => { }); }); +describe("welcome channel", () => { + const welcome = (message = "", title = "") => ({ message, title }); + // The viewer is a relay owner/admin but need not belong to the channel. + function welcomeStream(tags: string[][] = [["public"]], type = "stream") { + const h = harness("member", type); + const metadata = (landing: string[][] = []) => + h.record(39000, [ + ["d", id], + ["t", type], + ["name", "Lobby"], + ...tags, + ...landing, + ]); + h.setEvents([metadata(), ...h.getEvents().slice(1)]); + h.deny(); + h.publish.mockImplementation(async (event: RelayEvent) => { + if (event.kind !== 9002) return; + const text = event.tags.find(([key]) => key === "welcome")?.[1]; + const title = event.tags.find(([key]) => key === "welcome_title")?.[1]; + h.setEvents([ + metadata([ + ["landing"], + ...(text ? [["welcome", text]] : []), + ...(title ? [["welcome_title", title]] : []), + ]), + ...h.getEvents().filter((event) => event.kind !== 39000), + ]); + }); + return { ...h, metadata }; + } + + it("sends the full-state command and confirms the strong readback", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const h = welcomeStream(); + try { + await h.owner.capability.setLanding( + id, + welcome("# Say hi\n\n\t**here**", "Hello, Acme"), + ); + expect(h.sign.mock.calls[0]?.[0]).toEqual( + landingTemplate(id, welcome("# Say hi\n\n\t**here**", "Hello, Acme")), + ); + expect(h.sign.mock.calls[0]?.[0]?.tags).toEqual([ + ["h", id], + ["landing", "true"], + ["welcome", "# Say hi\n\n\t**here**"], + ["welcome_title", "Hello, Acme"], + ]); + expect(h.publish).toHaveBeenCalledOnce(); + // Only signed metadata is read, and the confirmation is strong. + for (const [filters] of h.read.mock.calls) + expect(filters.map((filter) => filter.kinds)).toEqual([[39000]]); + expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({ + consistency: "strong", + "#d": [id], + }); + // A channel the viewer cannot take part in stays out of discovery. + expect(h.acceptDiscovery).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + h.owner.dispose(); + } + }); + + it("restores the defaults with empty values and confirms their absence", async () => { + const h = welcomeStream(); + h.setEvents([ + h.metadata([ + ["landing"], + ["welcome", "Old"], + ["welcome_title", "Old title"], + ]), + ...h.getEvents().slice(1), + ]); + try { + await h.owner.capability.setLanding(id, welcome()); + expect(h.sign.mock.calls[0]?.[0]?.tags.slice(2)).toEqual([ + ["welcome", ""], + ["welcome_title", ""], + ]); + await expect(h.owner.capability.readLanding(id)).resolves.toEqual({ + landing: true, + name: "Lobby", + }); + } finally { + h.owner.dispose(); + } + }); + + it("reads the current message and title, ignoring them without the designation", async () => { + const h = welcomeStream(); + try { + h.setEvents([ + h.metadata([ + ["landing"], + ["welcome", "Hi"], + ["welcome_title", "Hello"], + ]), + ]); + await expect(h.owner.capability.readLanding(id)).resolves.toEqual({ + landing: true, + welcome: "Hi", + welcomeTitle: "Hello", + name: "Lobby", + }); + h.setEvents([ + h.metadata([ + ["welcome", "Stale"], + ["welcome_title", "Stale"], + ]), + ]); + await expect(h.owner.capability.readLanding(id)).resolves.toEqual({ + name: "Lobby", + }); + h.setEvents([]); + await expect(h.owner.capability.readLanding(id)).resolves.toBeUndefined(); + expect(h.read.mock.lastCall?.[0]?.[0]).toMatchObject({ + consistency: "strong", + }); + } finally { + h.owner.dispose(); + } + }); + + it("accepts the confirmed metadata into discovery for a channel the viewer is in", async () => { + const h = harness("member"); + h.publish.mockImplementation(async () => { + h.setEvents([ + h.record(39000, [["d", id], ["t", "stream"], ["public"], ["landing"]]), + ...h.getEvents().slice(1), + ]); + }); + h.setEvents([ + h.record(39000, [["d", id], ["t", "stream"], ["public"]]), + ...h.getEvents().slice(1), + ]); + try { + await h.owner.capability.setLanding(id, welcome()); + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + } finally { + h.owner.dispose(); + } + }); + + it.each([ + [[["public"], ["private"]], "stream"], + [[], "stream"], + [[["public"], ["archived", "true"]], "stream"], + [[["public"], ["ttl", "86400"]], "stream"], + [[["public"]], "forum"], + ])( + "refuses metadata %j on a %s channel before signing", + async (tags, type) => { + const h = welcomeStream(tags, type); + try { + await expect( + h.owner.capability.setLanding(id, welcome()), + ).rejects.toThrow( + "Only active public channels that don’t expire can welcome new members.", + ); + expect(h.sign).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + } + }, + ); + + it.each([ + [" ", ""], + ["a".repeat(4001), ""], + ["cr\rhere", ""], + ["", " "], + ["", "a".repeat(121)], + ["", "two\nlines"], + ["", "tab\there"], + ])( + "refuses an invalid message %j or title %j before reading or signing", + async (message, title) => { + const h = welcomeStream(); + try { + await expect( + h.owner.capability.setLanding(id, welcome(message, title)), + ).rejects.toThrow(); + expect(h.read).not.toHaveBeenCalled(); + expect(h.sign).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + } + }, + ); + + it("surfaces the relay's refusal and keeps an unconfirmed move uncertain", async () => { + const h = welcomeStream(); + try { + h.publish.mockRejectedValueOnce( + new PublishRejected( + "rejected", + "invalid: landing requires an open, unarchived channel without a ttl", + ), + ); + await expect( + h.owner.capability.setLanding(id, welcome()), + ).rejects.toMatchObject({ + reason: + "invalid: landing requires an open, unarchived channel without a ttl", + }); + // An acknowledged write whose readback never shows the designation. + h.publish.mockImplementationOnce(async () => {}); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const pending = expect( + h.owner.capability.setLanding(id, welcome()), + ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + await vi.advanceTimersByTimeAsync(2500); + await pending; + // A readback whose message or title differs is not a confirmation either. + h.publish.mockImplementationOnce(async () => { + h.setEvents([h.metadata([["landing"], ["welcome", "Other"]])]); + }); + const differing = expect( + h.owner.capability.setLanding(id, welcome("Mine")), + ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + await vi.advanceTimersByTimeAsync(2500); + await differing; + h.publish.mockImplementationOnce(async () => { + h.setEvents([h.metadata([["landing"], ["welcome", "Mine"]])]); + }); + const untitled = expect( + h.owner.capability.setLanding(id, welcome("Mine", "Hello")), + ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + await vi.advanceTimersByTimeAsync(2500); + await untitled; + } finally { + vi.useRealTimers(); + h.owner.dispose(); + } + }); + + it("publishes nothing once the caller's guard turns false", async () => { + const h = welcomeStream(); + try { + await expect( + h.owner.capability.setLanding(id, welcome(), undefined, () => false), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(h.publish).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + } + }); + + it("admits only the exact four-tag landing command", () => { + const base = { kind: 9002, content: "", created_at: 1 }; + for (const tags of [ + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", "a\tb"], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "Hello"], + ], + ]) + expect(() => validateLifecycleTemplate({ ...base, tags })).not.toThrow(); + for (const tags of [ + // The older form without a title is no longer admitted. + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ], + [ + ["h", id], + ["landing", "false"], + ["welcome", ""], + ["welcome_title", ""], + ], + [ + ["h", id], + ["welcome", ""], + ["landing", "true"], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", " "], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", "a\rb"], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", "", "extra"], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "two\nlines"], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "line\u2028sep"], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", " "], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome_title", ""], + ["welcome", ""], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", "", "extra"], + ], + [ + ["h", id], + ["landing", "true"], + ["welcome", ""], + ["welcome_title", ""], + ["welcome_title", ""], + ], + [ + ["h", id], + ["landing", "true"], + ], + [ + ["h", id], + ["landing", "true"], + ["name", "x"], + ], + ]) + expect(() => validateLifecycleTemplate({ ...base, tags })).toThrow(); + }); +}); + describe("caller publication guard", () => { function setup(action: "archive" | "unarchive") { const h = harness(); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index 92c7521ac0..cae961a89a 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -1,4 +1,4 @@ -import { getEventHash } from "nostr-tools"; +import { getEventHash, type EventTemplate } from "nostr-tools"; import { attestedOwner } from "../agents/owner-attestation"; import type { RelayReader } from "./reader"; import type { RelayWriter } from "./transport"; @@ -7,7 +7,8 @@ import { isTransientFailure, readErrorKind } from "./errors"; import { retryRead } from "./read-retry"; import { yieldToHost } from "./yield"; import { hasTag, newer, type RelayEvent } from "./events"; -import { openMetadata } from "./discovery"; +import { metadataName, openMetadata } from "./discovery"; +import { landingTags } from "./landing"; import { channelTypeTag, DM_VISIBILITY_KIND, @@ -16,9 +17,11 @@ import { lifecycleRecord, lifecycleSettings, lifecycleTemplate, + landingTemplate, ownerAgentGrant, type ChannelLifecycleCommand, type ChannelLifecycleSettings, + type WelcomeCommand, } from "./channel-lifecycle-protocol"; /** No automatic or same-dialog retry after a request may have reached the relay. */ @@ -31,6 +34,18 @@ export class ChannelLifecycleUnconfirmed extends Error { } } +/** A channel's signed welcome state. `welcome` and `welcomeTitle` are absent + * for the default message and title. */ +export type ChannelLanding = Readonly<{ + /** Signed metadata currently designates this channel. */ + landing?: true; + welcome?: string; + welcomeTitle?: string; + name?: string; + /** A starter channel: the relay adds people who join to it. */ + autoJoin?: true; +}>; + export type DmVisibility = Readonly<{ status: "idle" | "loading" | "ready" | "error"; hidden: readonly string[]; @@ -56,6 +71,20 @@ export interface ChannelLifecycleCapability { subscribe(listener: () => void): () => void; refreshVisibility(signal?: AbortSignal): Promise; observeRemoval(listener: (channelId: string) => void): () => void; + /** Strong read of relay-signed channel metadata; no membership needed. */ + readLanding( + channelId: string, + signal?: AbortSignal, + ): Promise; + /** Owner/admin command: make this the community's welcome channel with + * `welcome` as its message and title, `""` for either default. */ + setLanding( + channelId: string, + welcome: WelcomeCommand, + signal?: AbortSignal, + /** Checked synchronously right before publishing; false publishes nothing. */ + active?: () => boolean, + ): Promise; } export function createChannelLifecycle({ @@ -65,6 +94,7 @@ export function createChannelLifecycle({ relayAuthor, canAccess, acceptDiscovery, + acceptDirectory, removed, }: { reader?: RelayReader | undefined; @@ -73,6 +103,8 @@ export function createChannelLifecycle({ relayAuthor: string; canAccess(id: string): boolean; acceptDiscovery(events: readonly RelayEvent[]): void; + /** Display-only directory metadata, for any open channel. */ + acceptDirectory?: ((events: readonly RelayEvent[]) => void) | undefined; removed(id: string): void; }) { let closed = false; @@ -171,6 +203,54 @@ export function createChannelLifecycle({ signal.throwIfAborted(); } } + /** Each channel's commands keep strictly increasing timestamps. */ + function issue(id: string, fresh: EventTemplate): EventTemplate { + const template = { + ...fresh, + created_at: Math.max(fresh.created_at, (issued.get(id) ?? -1) + 1), + }; + issued.set(id, template.created_at); + return template; + } + async function sign( + signer: RelayWriter, + template: EventTemplate, + signal: AbortSignal, + ) { + const signed = await signer.sign(structuredClone(template), signal); + signal.throwIfAborted(); + if ( + signed.pubkey !== viewer || + signed.kind !== template.kind || + signed.created_at !== template.created_at || + signed.content !== template.content || + JSON.stringify(signed.tags) !== JSON.stringify(template.tags) || + getEventHash(signed) !== signed.id + ) + throw new Error("Signer changed the channel lifecycle command"); + return signed; + } + function pause(delay: number, signal: AbortSignal) { + signal.throwIfAborted(); + if (!delay) return Promise.resolve(); + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, delay); + signal.addEventListener("abort", abort, { once: true }); + }); + } + async function readMetadata(id: string, signal: AbortSignal, strong = false) { + const events = await reread(signal, () => + read([39000], id, signal, false, strong), + ); + return lifecycleRecord(events, 39000, id, relayAuthor); + } type OwnedAgentAction = "archive" | "unarchive" | "delete"; async function load( id: string, @@ -411,12 +491,11 @@ export function createChannelLifecycle({ return false; }; // The relay accepts the auto-join flag only from community owners - // and admins, a role the channel's own records cannot show. Check - // what signed metadata can: an active public stream channel. + // and admins, who need not belong to the channel; that role is not + // in the channel's own records. Check what signed metadata can: an + // active public stream channel. const flaggable = async () => { - assertAccess(id); const metadata = await read([39000], id, signal); - assertAccess(id); const channel = lifecycleRecord(metadata, 39000, id, relayAuthor); if ( !channel || @@ -461,23 +540,11 @@ export function createChannelLifecycle({ ); }; if (await authorize()) return; - const fresh = lifecycleTemplate(action, id); - const template = { - ...fresh, - created_at: Math.max(fresh.created_at, (issued.get(id) ?? -1) + 1), - }; - issued.set(id, template.created_at); - const signed = await writer.sign(structuredClone(template), signal); - signal.throwIfAborted(); - if ( - signed.pubkey !== viewer || - signed.kind !== template.kind || - signed.created_at !== template.created_at || - signed.content !== template.content || - JSON.stringify(signed.tags) !== JSON.stringify(template.tags) || - getEventHash(signed) !== signed.id - ) - throw new Error("Signer changed the channel lifecycle command"); + const signed = await sign( + writer, + issue(id, lifecycleTemplate(action, id)), + signal, + ); if (await authorize()) return; signal.throwIfAborted(); if (active?.() === false) @@ -487,19 +554,7 @@ export function createChannelLifecycle({ // An accepted write may precede its relay-owned side effects. Do not // optimistically revoke membership or confuse a failed read with absence. for (const delay of [0, 250, 750, 1500]) { - signal.throwIfAborted(); - if (delay) - await new Promise((resolve, reject) => { - const abort = () => { - clearTimeout(timer); - reject(signal.reason); - }; - const timer = setTimeout(() => { - signal.removeEventListener("abort", abort); - resolve(); - }, delay); - signal.addEventListener("abort", abort, { once: true }); - }); + await pause(delay, signal); if (action === "hide") { const hidden = await reread(signal, () => readVisibility(signal, true), @@ -527,7 +582,10 @@ export function createChannelLifecycle({ record.tags.some(([key]) => key === "auto_join") === (action === "auto-join-on") ) { - acceptDiscovery([record]); + // Only a channel this viewer reads joins the discovery state; + // the browsing directory lists every open channel. + if (canAccess(id)) acceptDiscovery([record]); + acceptDirectory?.([record]); return; } } else if (action === "archive" || action === "unarchive") { @@ -563,6 +621,94 @@ export function createChannelLifecycle({ busy = false; } }, + async readLanding(value: string, caller?: AbortSignal) { + const id = lifecycleChannelId(value); + return owned({ id, caller }, (signal) => + retryRead(async () => { + const record = await readMetadata(id, signal, true); + if (!record) return undefined; + // A strong read is current: the channel's projections take it too, + // so a check also refreshes its starter flag. + if (canAccess(id)) acceptDiscovery([record]); + acceptDirectory?.([record]); + const name = metadataName(record); + return Object.freeze({ + ...landingTags(record.tags), + ...(name ? { name } : {}), + ...(record.tags.some(([key]) => key === "auto_join") + ? { autoJoin: true as const } + : {}), + }); + }, signal), + ); + }, + async setLanding( + value: string, + welcome: WelcomeCommand, + caller?: AbortSignal, + active?: () => boolean, + ) { + if (!available || !writer) + throw new Error("Channel actions are unavailable on this connection"); + if (busy) throw new Error("Another channel action is still in progress"); + const id = lifecycleChannelId(value); + const template = landingTemplate(id, welcome); + busy = true; + const scope: Scope = { id, caller }; + try { + await owned(scope, async (signal) => { + // The relay accepts this only from community owners and admins, who + // need not belong to the channel. Check what signed metadata can + // show: an active public stream channel without an expiry. + const eligible = async () => { + const channel = await readMetadata(id, signal); + if ( + !channel || + !openMetadata(channel) || + exactLifecycleTag(channel, "archived") === "true" || + channelTypeTag(channel) !== "stream" || + channel.tags.some(([key]) => key === "ttl") + ) + throw new Error( + "Only active public channels that don’t expire can welcome new members.", + ); + }; + await eligible(); + const signed = await sign(writer, issue(id, template), signal); + await eligible(); + signal.throwIfAborted(); + if (active?.() === false) + throw new DOMException("Channel action cancelled", "AbortError"); + scope.published = true; + await writer.publish(signed, signal); + // Readback is the bare `landing` designation plus the stored message + // and title, each absent for its default. + for (const delay of [0, 250, 750, 1500]) { + await pause(delay, signal); + const record = await readMetadata(id, signal, true); + const state = record && landingTags(record.tags); + if ( + record && + state?.landing && + (state.welcome ?? "") === welcome.message && + (state.welcomeTitle ?? "") === welcome.title + ) { + if (canAccess(id)) acceptDiscovery([record]); + return; + } + } + throw new Error( + "The relay accepted the request, but the change is not confirmed.", + ); + }); + } catch (error) { + if (scope.published && !(error instanceof PublishRejected)) + throw new ChannelLifecycleUnconfirmed(error); + throw error; + } finally { + busy = false; + } + }, }); return { capability, diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 8c3b2448e9..2fff06c68e 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -53,6 +53,12 @@ export type ChannelSummary = Readonly<{ private?: true; /** Relay-advertised default: the relay joins new community members here. */ autoJoin?: true; + /** Relay-signed welcome channel: new members join and open it first. */ + landing?: true; + /** Custom welcome message (Markdown source); only on the welcome channel. */ + welcome?: string; + /** Custom welcome title; only on the welcome channel. */ + welcomeTitle?: string; /** Presentation-only parent from signed channel metadata; never grants access. */ parentChannelId?: string | undefined; /** Marked Huddle room; presented through its parent conversation, not the sidebar. */ @@ -309,7 +315,7 @@ export interface ChannelQueries { * resolves through `refer`. Empty while the directory is cold or in flight. */ openDirectory?(): readonly Pick< ChannelSummary, - "id" | "name" | "description" | "channelType" | "archived" + "id" | "name" | "description" | "channelType" | "archived" | "autoJoin" >[]; /** The open channels to browse: a first read's pages so far as they land, * otherwise `openDirectory`'s. Display only, like `openDirectory`. */ diff --git a/src/features/relay/discovery-landing.test.ts b/src/features/relay/discovery-landing.test.ts new file mode 100644 index 0000000000..ced7ba442e --- /dev/null +++ b/src/features/relay/discovery-landing.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "vitest"; +import { DiscoveryState } from "./discovery"; +import { keypair, metadata, roster } from "./testing"; + +it("marks the welcome channel and its message and title from relay-signed metadata", () => { + const viewer = keypair(), + relay = keypair(); + const state = new DiscoveryState(viewer.pubkey, relay.pubkey); + state.accept(roster(relay, "general", [viewer.pubkey])); + state.accept(metadata(relay, "general", "General")); + expect(state.get("general")?.landing).toBeUndefined(); + + state.accept( + metadata(relay, "general", "General", 1_700_000_001, [ + ["landing"], + ["welcome", "Say hi"], + ["welcome_title", "Hello"], + ]), + ); + expect(state.get("general")).toMatchObject({ + landing: true, + welcome: "Say hi", + welcomeTitle: "Hello", + }); + + // Moving the designation re-emits this channel without it; a surviving + // message no longer applies. + state.accept( + metadata(relay, "general", "General", 1_700_000_002, [ + ["welcome", "Say hi"], + ["welcome_title", "Hello"], + ]), + ); + expect(state.get("general")?.landing).toBeUndefined(); + expect(state.get("general")?.welcome).toBeUndefined(); + expect(state.get("general")?.welcomeTitle).toBeUndefined(); +}); + +it("ignores a non-relay-authored landing tag", () => { + const viewer = keypair(), + relay = keypair(), + stranger = keypair(); + const state = new DiscoveryState(viewer.pubkey, relay.pubkey); + state.accept(roster(relay, "general", [viewer.pubkey])); + state.accept(metadata(relay, "general", "General")); + expect( + state.accept( + metadata(stranger, "general", "General", 1_700_000_001, [["landing"]]), + ), + ).toBe(false); + expect(state.get("general")?.landing).toBeUndefined(); +}); diff --git a/src/features/relay/discovery.ts b/src/features/relay/discovery.ts index bc01b0ab37..44bb1f8a45 100644 --- a/src/features/relay/discovery.ts +++ b/src/features/relay/discovery.ts @@ -10,6 +10,7 @@ import { objectBody } from "./body"; import { newer, hasTag, tag, type RelayEvent } from "./events"; import type { ChannelSummary } from "./contracts"; import { channelIdentity } from "./channel-labels"; +import { landingTags } from "./landing"; const names = new WeakMap(); /** Display name from signed channel metadata; the content body overrides the tag. */ @@ -456,6 +457,7 @@ export class DiscoveryState { ...(this.hidden(id) ? { hidden: true } : {}), ...(this.isPrivate(id) ? { private: true } : {}), ...(this.autoJoin(id) ? { autoJoin: true } : {}), + ...(event ? landingTags(event.tags) : {}), ...(channelType ? { channelType } : {}), ...(channelType === "solo" && event ? { updatedAt: event.created_at } diff --git a/src/features/relay/earlier-activity.ts b/src/features/relay/earlier-activity.ts new file mode 100644 index 0000000000..19cf974592 --- /dev/null +++ b/src/features/relay/earlier-activity.ts @@ -0,0 +1,28 @@ +/** Whether the viewer wrote anything this relay can show them, of any kind and + * in any channel they can read, at or before `until` (unix seconds): one event + * answers the question. The only read without kinds or event IDs that the + * session reader and the browser broker admit, and only about the viewer. */ +export function earlierActivityFilter(viewer: string, until: number) { + return { authors: [viewer], until, limit: 1 }; +} + +/** Exactly the shape `earlierActivityFilter` builds for `viewer`; nothing + * more, and never about another author. */ +export function isEarlierActivityFilter( + filter: unknown, + viewer: string | undefined, +): boolean { + if (!filter || typeof filter !== "object") return false; + const { authors, until, limit, ...rest } = filter as Record; + return ( + !Object.keys(rest).length && + Array.isArray(authors) && + authors.length === 1 && + typeof authors[0] === "string" && + /^[0-9a-f]{64}$/.test(authors[0]) && + authors[0] === viewer && + Number.isSafeInteger(until) && + (until as number) >= 0 && + limit === 1 + ); +} diff --git a/src/features/relay/landing.test.ts b/src/features/relay/landing.test.ts new file mode 100644 index 0000000000..738e02dd04 --- /dev/null +++ b/src/features/relay/landing.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { + landingInfo, + landingTags, + normalizeWelcome, + validWelcomeCommand, + validWelcomeTitleCommand, + welcomeTextProblem, + welcomeTitleProblem, +} from "./landing"; + +describe("landingTags", () => { + it("reads the bare designation and its custom message and title", () => { + expect(landingTags([["d", "x"]])).toEqual({}); + expect(landingTags([["landing"]])).toEqual({ landing: true }); + expect(landingTags([["landing"], ["welcome", "Hi\nall"]])).toEqual({ + landing: true, + welcome: "Hi\nall", + }); + expect( + landingTags([ + ["landing"], + ["welcome", "# Hi\n\n\t**all**"], + ["welcome_title", "Hello, Acme"], + ]), + ).toEqual({ + landing: true, + welcome: "# Hi\n\n\t**all**", + welcomeTitle: "Hello, Acme", + }); + // Each part is customized on its own. + expect(landingTags([["landing"], ["welcome_title", "Hello"]])).toEqual({ + landing: true, + welcomeTitle: "Hello", + }); + }); + + it("ignores a message or title without the designation", () => { + // A stale tag can survive on the previous welcome channel. + expect( + landingTags([ + ["welcome", "Old"], + ["welcome_title", "Old"], + ]), + ).toEqual({}); + }); + + it("falls back to the default for a blank, repeated or invalid message or title", () => { + for (const tags of [ + [["landing"], ["welcome", ""]], + [["landing"], ["welcome", " "]], + [["landing"], ["welcome"]], + [["landing"], ["welcome", "a"], ["welcome", "b"]], + [["landing"], ["welcome", "cr\rhere"]], + [["landing"], ["welcome_title", ""]], + [["landing"], ["welcome_title", " "]], + [["landing"], ["welcome_title", "two\nlines"]], + [["landing"], ["welcome_title", "a"], ["welcome_title", "b"]], + ]) + expect(landingTags(tags)).toEqual({ landing: true }); + }); +}); + +describe("welcome text", () => { + it("normalizes line endings and trims before sending", () => { + expect(normalizeWelcome(" Hi\r\nall\rthere \n")).toBe("Hi\nall\nthere"); + }); + + it("mirrors the relay's limits in Unicode scalar values", () => { + expect(welcomeTextProblem("é".repeat(4000))).toBeUndefined(); + // Surrogate pairs count once, as Rust `chars()` does. + expect(welcomeTextProblem("😀".repeat(4000))).toBeUndefined(); + expect(welcomeTextProblem("a".repeat(4001))).toBe("long"); + expect(welcomeTextProblem(" \n ")).toBe("empty"); + expect(welcomeTextProblem("\u2003\u3000")).toBe("empty"); + // Markdown needs line breaks and tabs for structure and code. + expect(welcomeTextProblem("line\nbreak")).toBeUndefined(); + expect(welcomeTextProblem("- item\n\t- nested")).toBeUndefined(); + for (const text of [ + "cr\r", + "nul\u0000", + "vt\u000b", + "del\u007f", + "c1\u0085", + ]) + expect(welcomeTextProblem(text)).toBe("control"); + }); + + it("admits the empty default marker only as a command", () => { + expect(validWelcomeCommand("")).toBe(true); + expect(validWelcomeCommand("Hello")).toBe(true); + expect(validWelcomeCommand(" ")).toBe(false); + }); +}); + +describe("welcome title", () => { + it("mirrors the relay's one-line limits in Unicode scalar values", () => { + expect(welcomeTitleProblem("é".repeat(120))).toBeUndefined(); + expect(welcomeTitleProblem("😀".repeat(120))).toBeUndefined(); + expect(welcomeTitleProblem("a".repeat(121))).toBe("long"); + expect(welcomeTitleProblem(" ")).toBe("empty"); + for (const text of [ + "two\nlines", + "tab\t", + "cr\r", + "del\u007f", + "c1\u0085", + "line\u2028sep", + "para\u2029sep", + ]) + expect(welcomeTitleProblem(text)).toBe("control"); + // The message rejects neither separator; only the title is one line. + expect(welcomeTextProblem("line\u2028sep")).toBeUndefined(); + }); + + it("admits the empty default marker only as a command", () => { + expect(validWelcomeTitleCommand("")).toBe(true); + expect(validWelcomeTitleCommand("Hello")).toBe(true); + expect(validWelcomeTitleCommand(" ")).toBe(false); + }); +}); + +describe("welcome blank and direction rules", () => { + it("counts text of only whitespace and invisible characters as blank, as the relay does", () => { + for (const text of [ + "\u200B", + "\u2800", + " \u200B ", + "\uFEFF", + "\u00AD\u034F\u115F\u1160\u3164\uFFA0", + "\u180B\u2060\u206F\uFE00\uFE0F\uFFF9", + "\u{1D173}\u{E0000}\u{E007F}", + "\u{1BCA0}\u{1BCA3}\u{E0100}\u{E01EF}", + ]) { + expect(welcomeTextProblem(text)).toBe("empty"); + expect(welcomeTitleProblem(text)).toBe("empty"); + } + // Blank is checked first, so only direction marks are still empty. + expect(welcomeTextProblem("\u200E\u200F")).toBe("empty"); + // Invisible characters beside visible text are kept. + expect(welcomeTextProblem("Hello\u200Bworld")).toBeUndefined(); + expect(welcomeTitleProblem("Hello\u200Bworld")).toBeUndefined(); + }); + + it("refuses text-direction characters anywhere, before the length", () => { + for (const mark of [ + "\u061C", + "\u200E", + "\u200F", + "\u202A", + "\u202E", + "\u2066", + "\u2069", + ]) { + expect(welcomeTextProblem(`${mark}abc`)).toBe("bidi"); + expect(welcomeTitleProblem(`a${mark}bc`)).toBe("bidi"); + } + expect(welcomeTextProblem(`\u202E${"a".repeat(4001)}`)).toBe("bidi"); + expect(validWelcomeCommand("\u202Eabc")).toBe(false); + expect(validWelcomeTitleCommand("\u202Eabc")).toBe(false); + // A published value the relay would refuse shows the default. + expect( + landingTags([ + ["landing"], + ["welcome", "\u202Eabc"], + ["welcome_title", "\u200B"], + ]), + ).toEqual({ landing: true }); + }); +}); + +describe("landingInfo", () => { + const id = "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11"; + + it("reads support and the designated channel from NIP-11", () => { + expect( + landingInfo({ + supported_extensions: ["buzz-gif", "buzz-landing-channel"], + landing_channel: id, + }), + ).toEqual({ landingChannels: true, landingChannel: id }); + expect( + landingInfo({ supported_extensions: ["buzz-landing-channel"] }), + ).toEqual({ landingChannels: true }); + }); + + it("ignores a designation from a relay without the extension", () => { + expect(landingInfo({ landing_channel: id })).toEqual({ + landingChannels: false, + }); + expect(landingInfo(undefined)).toEqual({ landingChannels: false }); + }); + + it("ignores a malformed designation", () => { + for (const landing_channel of [id.toUpperCase(), "", null, 1, "general"]) + expect( + landingInfo({ + supported_extensions: ["buzz-landing-channel"], + landing_channel, + }), + ).toEqual({ landingChannels: true }); + }); +}); diff --git a/src/features/relay/landing.ts b/src/features/relay/landing.ts new file mode 100644 index 0000000000..1999702bf7 --- /dev/null +++ b/src/features/relay/landing.ts @@ -0,0 +1,113 @@ +/** The relay advertises the welcome-channel command in NIP-11 under this name. */ +export const LANDING_EXTENSION = "buzz-landing-channel"; +/** Relay limit for a custom welcome message, in Unicode scalar values. */ +export const WELCOME_MAX_LENGTH = 4000; +/** Relay limit for a custom welcome title, in Unicode scalar values. */ +export const WELCOME_TITLE_MAX_LENGTH = 120; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** The one value of a tag that appears exactly once. */ +function only(tags: readonly (readonly string[])[], name: string) { + const matches = tags.filter(([key]) => key === name); + return matches.length === 1 ? matches[0]?.[1] : undefined; +} + +/** + * Projects the welcome-channel tags from relay-signed channel metadata. The + * `welcome` and `welcome_title` tags count only while `landing` designates + * the channel; a blank, invalid or repeated value falls back to the default. + */ +export function landingTags(tags: readonly (readonly string[])[]): { + landing?: true; + welcome?: string; + welcomeTitle?: string; +} { + if (!tags.some(([name]) => name === "landing")) return {}; + const welcome = only(tags, "welcome"); + const title = only(tags, "welcome_title"); + return { + landing: true, + ...(welcome !== undefined && !welcomeTextProblem(welcome) + ? { welcome } + : {}), + ...(title !== undefined && !welcomeTitleProblem(title) + ? { welcomeTitle: title } + : {}), + }; +} + +/** Line endings normalize to `\n`; surrounding whitespace is not sent. */ +export function normalizeWelcome(text: string) { + return text.replace(/\r\n?/g, "\n").trim(); +} + +// The relay's blank rule: every code point is Unicode White_Space or one of +// these invisible ones (soft hyphen, fillers, joiners and direction marks, +// variation selectors, Braille blank, BOM, shorthand and musical format +// controls, tags and the like). The native host's validator lists the same. +const blank = (text: string) => + // biome-ignore lint/suspicious/noMisleadingCharacterClass: each listed code point is matched on its own, joiners and selectors included. + /^[\p{White_Space}\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2800\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF9-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]*$/u.test( + text, + ); +// Bidirectional marks, embeddings, overrides and isolates, refused anywhere. +const bidi = (text: string) => + /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u.test(text); + +/** Mirrors the relay's rules for a normalized, non-default message: Markdown + * source whose only control characters are line breaks and tabs. */ +export function welcomeTextProblem( + text: string, +): "empty" | "bidi" | "long" | "control" | undefined { + if (blank(text)) return "empty"; + if (bidi(text)) return "bidi"; + if ([...text].length > WELCOME_MAX_LENGTH) return "long"; + // biome-ignore lint/suspicious/noControlCharactersInRegex: the relay refuses these control characters. + if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(text)) return "control"; + return undefined; +} + +/** Mirrors the relay's rules for a trimmed, non-default title: one line with + * no control characters at all, nor the Unicode line and paragraph separators. */ +export function welcomeTitleProblem( + text: string, +): "empty" | "bidi" | "long" | "control" | undefined { + if (blank(text)) return "empty"; + if (bidi(text)) return "bidi"; + if ([...text].length > WELCOME_TITLE_MAX_LENGTH) return "long"; + // biome-ignore lint/suspicious/noControlCharactersInRegex: the relay refuses these control characters. + if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) return "control"; + return undefined; +} + +/** The command's message is either the default marker `""` or a valid text. */ +export function validWelcomeCommand(text: string) { + return text === "" || !welcomeTextProblem(text); +} + +/** The command's title is either the default marker `""` or a valid title. */ +export function validWelcomeTitleCommand(text: string) { + return text === "" || !welcomeTitleProblem(text); +} + +/** Relay support for a welcome channel and its current designation. */ +export type LandingInfo = Readonly<{ + landingChannels: boolean; + landingChannel?: string; +}>; + +/** Relay support and its currently designated channel, from NIP-11. */ +export function landingInfo(info: unknown): LandingInfo { + const record = + info && typeof info === "object" + ? (info as Record) + : undefined; + const extensions = record?.supported_extensions; + const supported = + Array.isArray(extensions) && extensions.includes(LANDING_EXTENSION); + const channel = record?.landing_channel; + return supported && typeof channel === "string" && UUID.test(channel) + ? { landingChannels: true, landingChannel: channel } + : { landingChannels: supported }; +} diff --git a/src/features/relay/reader.test.ts b/src/features/relay/reader.test.ts index 5df1f8e027..061e14ddbd 100644 --- a/src/features/relay/reader.test.ts +++ b/src/features/relay/reader.test.ts @@ -153,6 +153,31 @@ it("supports the desktop's ID, reference, address, and application-data reads wi } }); +it("admits the welcome card's author-only read without kinds, about the viewer alone", async () => { + const viewer = "a".repeat(64); + const scripted = scriptedTransport(viewer, "relay"); + const owner = createRelayReader(scripted.transport); + owners.push(owner); + const { next } = scripted; + const { read } = owner.reader; + const probe = { authors: [viewer], until: 1_788_810_000, limit: 1 }; + const result = read([probe]); + const wire = next(); + expect(wire.filters).toEqual([probe]); + wire.respond([]); + await expect(result).resolves.toEqual([]); + for (const refused of [ + { authors: [viewer], limit: 1 }, + { authors: [viewer], until: 1, limit: 2 }, + { authors: [viewer], until: 1, limit: 1, "#h": ["channel"] }, + { authors: [viewer, "b".repeat(64)], until: 1, limit: 1 }, + { authors: ["b".repeat(64)], until: 1_788_810_000, limit: 1 }, + ]) + await expect(read([refused])).rejects.toThrow( + "Relay reads need valid kinds or event IDs", + ); +}); + it("preserves ordered bridge extensions and distinguishes their request identity", async () => { const { read, next, pending } = setup(); const first = read([ diff --git a/src/features/relay/reader.ts b/src/features/relay/reader.ts index 785c63ffcb..eae561dcaa 100644 --- a/src/features/relay/reader.ts +++ b/src/features/relay/reader.ts @@ -1,4 +1,5 @@ import { isSidebarPreferencesBatch } from "./sidebar-preferences"; +import { isEarlierActivityFilter } from "./earlier-activity"; import { isWorkflowDefinitionBatch } from "../workflows/queries"; import { verifyThreadWindows } from "./thread-window"; import { yieldToHost } from "./yield"; @@ -277,7 +278,8 @@ export function createRelayReader( (kind) => Number.isInteger(kind) && kind >= 0 && kind <= 65535, ) ) && - !(filter.kinds === undefined && filter.ids?.length) + !(filter.kinds === undefined && filter.ids?.length) && + !isEarlierActivityFilter(filter, transport.viewer) ) throw new Error("Relay reads need valid kinds or event IDs"); if ( diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 0074781ea5..ba035f4aec 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1176,6 +1176,7 @@ export function createRelaySession( relayAuthor: transport?.relayAuthor ?? "", canAccess: (id) => !closed && channels.canParticipate(id), acceptDiscovery: (events) => channels.acceptDiscovery(events), + acceptDirectory: (events) => channels.acceptDirectory(events), removed: (id) => { channels.denyChannel(id, new Error("Channel is no longer available")); dropStrandedSends(); diff --git a/src/features/relay/store-discovery.test.ts b/src/features/relay/store-discovery.test.ts index d4d0dd7b35..3d89e30bac 100644 --- a/src/features/relay/store-discovery.test.ts +++ b/src/features/relay/store-discovery.test.ts @@ -9,6 +9,7 @@ import { createRelaySession } from "./session"; import type { ChannelSummary } from "./contracts"; import type { ChannelStoreOptions } from "./store"; import { isRetryableRead } from "./read-retry"; +import { matchesEvent } from "./projection"; import { flush, keypair, @@ -1123,14 +1124,15 @@ it("merges incremental metadata and periodically replaces the full directory", a delta.request.respond([ open("renamed", "after", start / 1000 + 1), open("archived", "active", start / 1000 + 2, [["archived", "true"]]), - open("new", "new-channel", start / 1000 + 3), + // The relay joins new members to a channel with the bare tag. + open("new", "new-channel", start / 1000 + 3, [["auto_join"]]), ]); expect(await delta.search).toEqual({ channels: [], partial: false }); expect(h.channels.openDirectory?.()).toEqual([ { id: "renamed", name: "after" }, { id: "archived", name: "active", archived: true }, { id: "future", name: "future" }, - { id: "new", name: "new-channel" }, + { id: "new", name: "new-channel", autoJoin: true }, ]); clock += 30_000; const empty = await read(); @@ -1146,7 +1148,7 @@ it("merges incremental metadata and periodically replaces the full directory", a clock = start + 5 * 60_000; const full = await read(); expect(full.request.filters[0]?.since).toBeUndefined(); - full.request.respond([open("new", "new-channel", start / 1000 + 3)]); + full.request.respond([open("new", "new-channel", start / 1000 + 4)]); await full.search; expect(h.channels.openDirectory?.()).toEqual([ { id: "new", name: "new-channel" }, @@ -1493,6 +1495,67 @@ it("reads the open directory for browsing with descriptions, sharing the search expect(h.pending).toHaveLength(0); }); +it("shows a confirmed auto-join change in the directory before any directory re-read", async () => { + // An owner or admin flags channels they are not in, so the directory, not + // the list, carries this one. + const admin = keypair(); + const id = "13050000-0000-4000-8000-000000000001"; + let flagged = true; + const channel = () => + metadata(relay, id, "starter", flagged ? 1_700_000_000 : 1_700_000_001, [ + ["public"], + ["t", "stream"], + ...(flagged ? [["auto_join"]] : []), + ]); + const directoryReads = vi.fn(); + const owner = createRelaySession( + { + viewer: admin.pubkey, + relayAuthor: relay.pubkey, + media: () => undefined, + query: async (filters) => { + if ( + filters.some((filter) => filter.kinds?.[0] === 39000 && !filter["#d"]) + ) + directoryReads(); + return [channel()].filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ); + }, + channelLifecycle: { + sign: async (template) => signed(admin, template), + publish: async (event) => { + if (event.kind === 9002) flagged = false; + }, + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + owners.push(owner); + const { channels } = owner.session; + channels.ensureList(); + await vi.waitFor(() => expect(channels.list().status).toBe("ready")); + await channels.readDirectory?.(); + expect(channels.browseDirectory?.()).toEqual([ + { id, name: "starter", channelType: "stream", autoJoin: true }, + ]); + const listeners = vi.fn(); + const off = channels.subscribeDirectory?.(listeners); + try { + await owner.session.channelLifecycle.run("auto-join-off", id); + // Within the reuse window: the readback, not another directory read. + const unflagged = [{ id, name: "starter", channelType: "stream" }]; + expect(channels.browseDirectory?.()).toEqual(unflagged); + expect(channels.openDirectory?.()).toEqual(unflagged); + expect(listeners).toHaveBeenCalled(); + expect(await channels.readDirectory?.()).toEqual({ partial: false }); + expect(channels.browseDirectory?.()).toEqual(unflagged); + expect(directoryReads).toHaveBeenCalledOnce(); + } finally { + off?.(); + } +}); + it("shows a first directory read's pages to browsing only, and names them for references once it finishes", async () => { const h = setup({ publicDirectoryPage: 2, maxPublicDirectory: 10 }); h.channels.ensureList(); diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts index 5569ba0af9..884c119fb7 100644 --- a/src/features/relay/store.test.ts +++ b/src/features/relay/store.test.ts @@ -120,6 +120,46 @@ describe("channel store", () => { expect(queries.list().channels[0]?.autoJoin).toBe(true); store.dispose(); }); + it("replaces a channel summary when only its welcome state changes", async () => { + const { store, queries, next } = setup(); + queries.ensureList(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + metadata(relay, "work", "Work", 1_700_000_000, [["landing"]]), + ]); + await flush(); + const landing = queries.list().channels[0]; + expect(landing).toMatchObject({ landing: true }); + expect(landing?.welcome).toBeUndefined(); + + queries.refreshList?.(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + metadata(relay, "work", "Work", 1_700_000_001, [ + ["landing"], + ["welcome", "Hello"], + ]), + ]); + await flush(); + const welcomed = queries.list().channels[0]; + expect(welcomed).not.toBe(landing); + expect(welcomed?.welcome).toBe("Hello"); + + // A title alone is a change too. + queries.refreshList?.(); + next().respond([ + roster(relay, "work", [viewer.pubkey]), + metadata(relay, "work", "Work", 1_700_000_002, [ + ["landing"], + ["welcome", "Hello"], + ["welcome_title", "Hi, Work"], + ]), + ]); + await flush(); + expect(queries.list().channels[0]).not.toBe(welcomed); + expect(queries.list().channels[0]?.welcomeTitle).toBe("Hi, Work"); + store.dispose(); + }); it("preserves summary identity on preview refreshes and detects same-length DM roster changes", () => { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let live!: LiveCallbacks; diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index dbf21bed1c..75618a3be3 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -449,6 +449,9 @@ export function createChannelStore( old.hidden === channel.hidden && old.private === channel.private && old.autoJoin === channel.autoJoin && + old.landing === channel.landing && + old.welcome === channel.welcome && + old.welcomeTitle === channel.welcomeTitle && old.channelType === channel.channelType && old.parentChannelId === channel.parentChannelId && old.huddle === channel.huddle && @@ -2348,6 +2351,9 @@ export function createChannelStore( open?: ReturnType>; /** A first read's open channels so far, for browsing only. */ pages?: ReturnType>; + /** Metadata confirmed outside this read while it runs, which a + * failure hands back to the prior read. */ + accepted?: RelayEvent[]; } | undefined; const noOpenDirectory: ReturnType< @@ -2380,6 +2386,10 @@ export function createChannelStore( ...(type === "stream" || type === "forum" ? { channelType: type } : {}), ...(tag(event, "about") ? { description: tag(event, "about") } : {}), ...(hasTag(event, "archived", "true") ? { archived: true as const } : {}), + // The bare tag says the relay joins new members here now. + ...(event.tags.some(([key]) => key === "auto_join") + ? { autoJoin: true as const } + : {}), }); } function openDirectoryChannels(events: readonly RelayEvent[]) { @@ -2561,6 +2571,7 @@ export function createChannelStore( entry.partial = partial; delete entry.previous; delete entry.pages; + delete entry.accepted; // Name references in mounted rows read the directory through the // list subscription; the list itself is unchanged. entry.open = openDirectoryChannels(entry.events); @@ -2573,6 +2584,7 @@ export function createChannelStore( if (publicDirectory === entry) { publicDirectory = prior; if (entry.pages) notify(directoryListeners); + if (entry.accepted) acceptDirectory(entry.accepted); } land(); }, @@ -2580,6 +2592,42 @@ export function createChannelStore( } return publicDirectory; } + /** Fold relay-signed metadata confirmed outside a directory read, such as + * a lifecycle command's readback, into this epoch's directory, so browsing + * and name references show it before the next read. The read in flight, or + * the next one, keeps it unless the relay has newer metadata. Display only, + * like the directory itself. */ + function acceptDirectory(events: readonly RelayEvent[]) { + const entry = publicDirectory; + if (disposed || !transport || entry?.generation !== epoch) return; + const author = transport.relayAuthor; + const fresh = events.filter( + (event) => + event.kind === 39000 && event.pubkey === author && !!tag(event, "d"), + ); + if (!fresh.length) return; + entry.events.push(...fresh); + if (entry.at !== undefined) + entry.open = openDirectoryChannels(entry.events); + else { + // The read folds them in when it finishes; until then, patch the rows + // shown meanwhile. + entry.accepted = [...(entry.accepted ?? []), ...fresh]; + const latest = latestPublicMetadata(fresh); + const patch = (rows: readonly DirectoryChannel[]) => + Object.freeze( + rows.flatMap((row) => { + const event = latest.get(row.id); + const channel = event ? directoryChannel(row.id, event) : row; + return channel ? [channel] : []; + }), + ); + if (entry.open) entry.open = patch(entry.open); + if (entry.pages) entry.pages = patch(entry.pages); + } + notify(listListeners); + notify(directoryListeners); + } /** Read the whole open-channel directory, for browsing. Shares the search * read; `openDirectory` then names its channels. Never resolves them. */ async function readDirectory( @@ -4171,6 +4219,7 @@ export function createChannelStore( }, suspendedPreviews: () => discovery?.suspendedChannels() ?? [], acceptDiscovery: applyDiscovery, + acceptDirectory, accept, clearCache, dispose, diff --git a/src/shared/view-state.ts b/src/shared/view-state.ts index e5a8a8e3f0..be546303a7 100644 --- a/src/shared/view-state.ts +++ b/src/shared/view-state.ts @@ -176,9 +176,11 @@ export function listDraftViews( return { entries: drafts, limited, unavailable }; } -/** Forgets every key saved under one scope, for a community the viewer has left. */ -export function clearViewScope(scope: string) { - const prefix = `buzz-view.v1:${JSON.stringify([scope]).slice(0, -1)},`; +/** Forgets every key saved under one scope, for a community the viewer has + * left, or only the keys there that start with `keyPrefix`. */ +export function clearViewScope(scope: string, keyPrefix = "") { + // JSON escapes per character, so an encoded key starts with its encoded prefix. + const prefix = `buzz-view.v1:${JSON.stringify([scope, keyPrefix]).slice(0, -2)}`; const stale: string[] = []; for (let index = 0; index < localStorage.length; index++) { const key = localStorage.key(index); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index a1f66b3a94..2203c6b1ec 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -200,9 +200,13 @@ export function policyRelay({ try { if (latencyMs) await new Promise((resolve) => setTimeout(resolve, latencyMs)); - // NIP-11 is a public GET with no signed query body. The rail now reads - // it for saved communities, including inactive ones. + // NIP-11 and the join policy are public GETs with no signed query body. + // The rail reads NIP-11 for saved communities, including inactive ones; + // Messages reads both (the broker's info route) for the welcome channel. + // These communities are open: they publish no join policy. if (!init?.body) { + if (new URL(url).pathname === "/api/join-policy") + return new Response(null, { status: 404 }); expect(new URL(url).pathname).toBe("/"); return Response.json(discovery?.(communityOf(url)) ?? {}); } diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx index 0a356875d0..79d7f04b81 100644 --- a/tests/fixtures/conversation.tsx +++ b/tests/fixtures/conversation.tsx @@ -320,7 +320,7 @@ ctx.provide("agents", { snapshot: () => noAgents, subscribe: () => () => {}, }); -// Channels hands community membership to its page for the auto-join switch; no roster here. +// Channels hands community membership to its page for the first-channel setup. ctx.provide("adminAccess", { membership: undefined }); Object.assign(window, { conversationFixture: { diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index b7f75e7ade..e785a29c56 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -381,7 +381,7 @@ if (channels) { sync: emptySync(), }), }); - // Channels hands community membership to its page for the auto-join switch; no roster here. + // Channels hands community membership to its page for the first-channel setup. context.provide("adminAccess", { membership: undefined }); // Only the composer is mounted; the Channels page never consumes this service. context.provide("relay", {