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`)