From e470be6c8c31d545f6e71236206fe9d6925986d3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 04:06:15 +0800 Subject: [PATCH] Show the group strip the room's live link instead of minting another MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group's own header (#998) offers "Invite by link" while the chat has said nothing. It minted on the click and never read the links the room already had, so leaving the group and coming back before sending anything offered the button again and minted a second ticket: the room's list grew on every visit, and the link already sent to someone could not be reached again from that strip (#1019). The strip now reads `room-invite` on mount, exactly as the Members card's row does (#876): the newest link that still works is shown, with Copy, and nothing is minted while there is one. The deadline the host keeps in milliseconds is read through the same ISO conversion the card uses, so a link that still works is not called expired. The link is held with the room it was read for: a dock handed to another Session clears it in the same render, so the next group never shows — or copies — the previous room's invite. An expired link is not live, so with none left the button is back and mints; several open links still all work (#878), and this strip shows the newest live one. Fixes #1019 --- ...1019-group-strip-reuses-live-link.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 40 ++++++- plugins/social/test/social.test.js | 106 +++++++++++++++++- 4 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 changelog.d/1019-group-strip-reuses-live-link.fixed.md diff --git a/changelog.d/1019-group-strip-reuses-live-link.fixed.md b/changelog.d/1019-group-strip-reuses-live-link.fixed.md new file mode 100644 index 000000000..324158c36 --- /dev/null +++ b/changelog.d/1019-group-strip-reuses-live-link.fixed.md @@ -0,0 +1 @@ +Coming back to a group nobody has spoken in yet shows the invite link the room already has, instead of minting another one on every visit — the strip now reads the room's links first, as the Members card does, counts a dated link live until its deadline passes, and never keeps the previous room's link when the dock moves to another group ([#1019](https://github.com/ThinkFlowLab/FlyBot/issues/1019)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 799bde645..e03a6c917 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -94,7 +94,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### A group's own header before its first turn (#998) -Creating a group opens its Session with zero turns, and the host draws no session header for a Session that has said nothing — so a group made from **Create group chat** showed only its sidebar row until the first send. The dock entry `social-group-strip` (`flybot.session.dock`, order 30) stands in for that header exactly while the dock's `blank` prop is true — nothing said and nothing sent, the state in which the host draws no header: the group's name, its members' names and **Invite by link** (the same `room-invite-mint` route the Members card uses, shown inline with Copy). The first *send* ends it: `blank` needs nothing said *and* nothing sent, so the host's own header takes over from that click and the two never draw together. A face room keeps its own line (`social-room-end`) and gets no strip. +Creating a group opens its Session with zero turns, and the host draws no session header for a Session that has said nothing — so a group made from **Create group chat** showed only its sidebar row until the first send. The dock entry `social-group-strip` (`flybot.session.dock`, order 30) stands in for that header exactly while the dock's `blank` prop is true — nothing said and nothing sent, the state in which the host draws no header: the group's name, its members' names and, when the room has no live link yet, **Invite by link** (the same `room-invite-mint` route the Members card uses, shown inline with Copy). It reads the room's links first, the way the Members card does (#1019), so coming back to a group with a link already out shows that link and mints nothing: leaving and returning never grows the room's list of open tickets. A link counts as live only while its deadline has not passed — the host dates links in milliseconds and this panel reads an ISO time, the same conversion the Members card makes (#876) — and with no live link left the button is back. Several open links all still work (#878); this strip shows the newest live one. A dock handed to another Session never keeps the previous room's link: the link is held with the room id it was read for, so switching groups clears it in the same render and **Copy** can never copy another room's invite. The first *send* ends it: `blank` needs nothing said *and* nothing sent, so the host's own header takes over from that click and the two never draw together. A face room keeps its own line (`social-room-end`) and gets no strip. ### Face-room human turns and UI polish (#166, #167) diff --git a/plugins/social/client.js b/plugins/social/client.js index 074daae8c..cdd2d0f33 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1002,6 +1002,20 @@ window.__ModuleLoader__.load({ .replace(/\s+/g, ' ') .trim() .slice(0, ROOM_NAME_MAX) || fallback + // Which link of a room a panel may show (#1019): one that still works. The host answers `room-invite` + // with the newest open link and every open link, so an expired newest one (an older Bot Social still + // dates links) does not hide a later one that is still good, and a link nobody can join with is never + // called live. Shared by the Members card's read and the group strip's. + const liveInviteOf = (value) => { + // The host keeps a deadline in milliseconds and this card reads an ISO time, the same unit the + // Members card converts (#876): handing the number straight to `qrJoinable` makes Date.parse return + // NaN, which calls a link that still works expired (#1019). + const at = item => (item?.expiresAt ? isoAt(item.expiresAt) : 'open') + const open = (Array.isArray(value?.invites) ? value.invites : []) + .filter(item => item?.url && qrJoinable(at(item), Date.now())) + const newest = value?.invite?.url && qrJoinable(at(value.invite), Date.now()) ? value.invite : null + return newest ?? open.at(-1) ?? null + } // The card is core's, not this plugin's (#876): `invitePreviewOf` and `RoomInvitePreview` are // registered by ds-bot's client bundle, so the face-room paste flow and the member-invite dialog // draw one tree of elements. This file keeps no copy of the markup. @@ -2149,11 +2163,29 @@ window.__ModuleLoader__.load({ // way to invite someone. The first message ends it and the host's own header takes over. function GroupStrip({ room, roster, blank } = {}) { useStyles() - const [invite, setInvite] = useState(null) + const roomId = room?.id + // The link is kept with the room it belongs to: a session dock is handed to another Session without + // unmounting, so a bare link would stay on screen for the next group and Copy would copy another + // room's invite (#1019). Reading it through the room id also clears it in the same render that draws + // the new room — no frame with the old room's URL. + const [shown, setShown] = useState({ roomId: '', invite: null }) const [busy, setBusy] = useState(false) const [note, setNote] = useState(null) const [copied, setCopied] = useState(false) - if (blank !== true || !room?.id || room?.face) return null + const invite = shown.roomId === roomId ? shown.invite : null + // The link this room already has (#1019): coming back to a group nobody has spoken in must show the + // link that was minted before, not mint another one. Same read the Members card's row makes (#876), + // so a group with a live link has nothing to mint and its list does not grow on every visit. + useEffect(() => { + if (!roomId) return undefined + let gone = false + host('room-invite', { roomId }).then((value) => { + if (gone) return + setShown({ roomId, invite: liveInviteOf(value) }) + }, () => {}) + return () => { gone = true } + }, [roomId]) + if (blank !== true || !roomId || room?.face) return null const memberNames = (Array.isArray(room.members) ? room.members : []) .map(id => oneLine(roster?.byId?.[id]?.name ?? roster?.bots?.find?.(bot => bot.id === id)?.name ?? '')) .filter(Boolean) @@ -2161,8 +2193,8 @@ window.__ModuleLoader__.load({ setBusy(true) setNote(null) try { - const value = await host('room-invite-mint', { roomId: room.id }) - if (value?.invite?.url) setInvite(value.invite) + const value = await host('room-invite-mint', { roomId }) + if (value?.invite?.url) setShown({ roomId, invite: value.invite }) else setNote(tr('No link came back. Try again.')) } catch (error) { setNote(errorText(error)) } finally { setBusy(false) } } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 7d5564c45..37d94a420 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -6670,6 +6670,82 @@ test('#998: a zero-turn group draws its own strip above the composer, and drops assert.equal(plugin.GroupStrip({ room: undefined, roster, blank: true }), null, 'a Bot chat has no group strip') }) +// #1019: coming back to a group nobody has spoken in must show the link the room already has, not mint +// another one. The strip reads `room-invite` the way the Members card does, and a link nobody can join +// with is not live: an expired one is never shown, and its absence is what puts the button back. +// #1019: coming back to a group nobody has spoken in must show the link the room already has, not mint +// another one. The strip reads `room-invite` the way the Members card does, a link nobody can join with is +// not live (a deadline is milliseconds, and the card reads an ISO time), and a dock handed another Session +// never keeps showing — or copying — the room it used to be on. +test('#1019: the group strip shows the room\'s live link and mints only when there is none', async () => { + const link = (ticket, expiresAt = null) => ({ ticket, handle: '@chief.alice.home', url: `https://s/r/@chief.alice.home/${ticket}`, expiresAt, server: true }) + const existing = link('LiveAbCdEfGhIjKlMnOpQr') + const calls = [] + let invites = [existing] + const host = sharingHost(async () => ({}), async (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'room-invite') { + const list = invites.filter(item => item.roomId === undefined || item.roomId === payload.roomId) + return { invite: list.at(-1) ?? null, invites: list } + } + if (endpoint === 'room-invite-mint') { + const made = link(`Fresh${payload.roomId}AbCdEfGhIj`, Date.now() + 3600_000) + invites = [...invites, { ...made, roomId: payload.roomId }] + return { invite: made } + } + return {} + }) + const mints = () => calls.filter(([endpoint]) => endpoint === 'room-invite-mint').length + const button = panel => panel.nodes().find(n => n.type === 'button' && n.props?.['data-flybot'] === 'group-strip-invite') + const url = panel => panel.nodes().find(n => n.props?.['data-flybot'] === 'group-strip-url')?.props.children.join('') + const roster = { byId: { b1: { id: 'b1', name: 'Chief' } }, bots: [] } + const room = { id: 'room-1', name: 'Launch plan', members: ['b1'] } + let panel = host.mount('GroupStrip', { room, roster, blank: true }) + await panel.settle() + assert.equal(url(panel), existing.url, 'the link the room already has, not a fresh one') + assert.equal(panel.has('group-strip-invite'), false, 'so there is nothing to mint') + assert.equal(mints(), 0) + // A second visit is a second mount of the same blank group: same link, still no minting. + panel = host.mount('GroupStrip', { room, roster, blank: true }) + await panel.settle() + assert.equal(url(panel), existing.url, 'the same link on the way back') + assert.equal(mints(), 0, 'no second ticket') + assert.equal(invites.length, 1, 'the room still holds one link') + // A deadline in the future is a live link: the host dates it in milliseconds (#1019 review). + invites = [{ ...link('LaterAbCdEfGhIjKlMnOp', Date.now() + 3600_000), roomId: 'room-1' }] + panel = host.mount('GroupStrip', { room, roster, blank: true }) + await panel.settle() + assert.equal(url(panel), invites[0].url, 'a link that still works is shown, not called expired') + assert.equal(mints(), 0, 'and it is not replaced') + // An expired link is not live: no URL, the button is back, and it mints one. + invites = [{ ...link('GoneAbCdEfGhIjKlMnOpQr', Date.now() - 60_000), roomId: 'room-1' }] + panel = host.mount('GroupStrip', { room, roster, blank: true }) + await panel.settle() + assert.equal(panel.has('group-strip-url'), false, 'an expired link is never drawn as live') + assert.ok(button(panel), 'with no live link, inviting is the only thing left') + await panel.click('group-strip-invite') + await panel.settle() + assert.equal(url(panel), 'https://s/r/@chief.alice.home/Freshroom-1AbCdEfGhIj', 'the minted link is the one on screen') + assert.equal(invites.length, 2, 'and the expired one is still listed by the room, just not shown') + // The dock is handed another Session without unmounting: room B keeps its own link, and never A's. + invites = [{ ...link('RoomAAbCdEfGhIjKlMnOpQ', null), roomId: 'room-a' }] + panel = host.mount('GroupStrip', { room: { ...room, id: 'room-a', name: 'Plan A' }, roster, blank: true }) + await panel.settle() + assert.equal(url(panel), 'https://s/r/@chief.alice.home/RoomAAbCdEfGhIjKlMnOpQ') + invites = [...invites, { ...link('RoomBAbCdEfGhIjKlMnOpQ', null), roomId: 'room-b' }] + panel.update({ room: { ...room, id: 'room-b', name: 'Plan B' } }) + assert.equal(url(panel), undefined, 'the old room\'s link is off screen before the new room is read') + await panel.settle() + assert.equal(url(panel), 'https://s/r/@chief.alice.home/RoomBAbCdEfGhIjKlMnOpQ', 'and the new room\'s own link takes its place') + assert.equal(mints(), 1, 'switching rooms mints nothing') + // Back to a room with no live link: the button returns, and clicking it mints for that room. + invites = invites.filter(item => item.roomId !== 'room-a') + panel.update({ room: { ...room, id: 'room-a', name: 'Plan A' } }) + assert.equal(panel.has('group-strip-url'), false, 'no live link in A any more') + await panel.settle() + assert.ok(button(panel), 'and the button is back for it') +}) + test('#297: the thinking line is a plain session dock below the approval card, styled like the other quiet notes', () => { const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') assert.match(source, /register\('flybot\.session\.dock', \{ id: 'social-thinking', order: 60, component: PeerThinking \}\)/) @@ -10980,7 +11056,7 @@ test('#968: the social dock reads the shared predicate, not only the flag it use // gets its own hook store through a current-store dispatcher. `useState` re-renders for real, the // 5 s polls are captured instead of waited on, and effects run once — enough to drive a failed or // racing `social/share-friends` read without a browser or a fake clock. -function sharingHost(call) { +function sharingHost(call, hostCalls) { const registered = [] const polls = [] const sandbox = { @@ -10992,6 +11068,8 @@ function sharingHost(call) { } sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: () => () => {} }, call } + // #1019: the panel that draws a room link calls the host endpoints through `__flybot__.host`. + if (hostCalls) sandbox.__flybot__.host = hostCalls runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat() } }) sandbox.__flybot__.BotAvatar = () => h('span', { 'data-flybot': 'bot-avatar' }) @@ -11007,27 +11085,41 @@ function sharingHost(call) { useState: init => store.useState(init), useRef: value => store.useRef(value), useMemo: fn => fn(), - useEffect: fn => store.useEffect(fn), + useEffect: (fn, deps) => store.useEffect(fn, deps), } const plugin = registered[0].factory(() => hooks) const all = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); for (const child of node.props?.children ?? []) all(child, out) } return out } const text = node => (typeof node === 'string' ? node : (node?.props?.children ?? []).map(text).join('')) const mount = (component, props) => { - const self = { hook: 0, states: [], refs: [], effects: [], tree: null } + const self = { hook: 0, states: [], refs: [], effects: [], deps: [], seen: [], tree: null, props } const pollStart = polls.length - const render = () => { const previous = store; store = self; self.hook = 0; self.tree = plugin[component](props); store = previous } + const render = () => { const previous = store; store = self; self.hook = 0; self.effects = []; self.deps = []; self.tree = plugin[component](self.props); store = previous } + // React's rule, as far as these panels need it: an effect runs when its deps change — a session dock + // handed another Session is a props change, not a mount (#1019). No deps: once. + const runEffects = () => { + for (let i = 0; i < self.effects.length; i++) { + const fn = self.effects[i] + if (!fn) continue + const deps = self.deps[i] + const key = Array.isArray(deps) ? JSON.stringify(deps) : 'once' + if (self.seen[i] === key) continue + self.seen[i] = key + const previous = store; store = self; fn(); store = previous + } + } self.useState = (init) => { const i = self.hook++ if (!(i in self.states)) self.states[i] = typeof init === 'function' ? init() : init return [self.states[i], value => { self.states[i] = typeof value === 'function' ? value(self.states[i]) : value render() + runEffects() }] } self.useRef = (value) => { const i = self.hook++; if (!(i in self.refs)) self.refs[i] = { current: value }; return self.refs[i] } - self.useEffect = (fn) => { const i = self.hook++; if (!(i in self.effects)) self.effects[i] = fn } + self.useEffect = (fn, deps) => { const i = self.hook++; self.effects[i] = fn; self.deps[i] = deps } render() - for (const fn of [...self.effects]) { const previous = store; store = self; fn?.(); store = previous } + runEffects() const nodes = () => all(self.tree) const settle = async () => { for (let i = 0; i < 6; i++) await new Promise(r => setTimeout(r, 0)) } return { @@ -11036,6 +11128,8 @@ function sharingHost(call) { settle, // One poll tick of this panel, exactly what the 5 s timer does. poll: () => polls[pollStart]?.(), + // The same mounted panel handed another Session: props change, the component stays. + update: (next) => { self.props = { ...self.props, ...next }; render(); runEffects() }, click: async (name) => { const node = nodes().find(n => n.type === 'button' && n.props?.['data-flybot'] === name) if (!node) throw new Error(`no ${name} button`)