diff --git a/changelog.d/1038-group-strip-waits-for-its-read.fixed.md b/changelog.d/1038-group-strip-waits-for-its-read.fixed.md new file mode 100644 index 000000000..a040814ee --- /dev/null +++ b/changelog.d/1038-group-strip-waits-for-its-read.fixed.md @@ -0,0 +1 @@ +The group strip no longer offers **Invite by link** before it has read the room's own links, so a fast click cannot mint a second ticket the panel was about to discover, and a chat that draws no strip (a face room, a Bot chat, a chat with turns) no longer issues that read at all ([#1038](https://github.com/ThinkFlowLab/FlyBot/issues/1038)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 35378319c..9a893d7fc 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, 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. +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. The strip reads nothing and draws nothing for a chat that is not a blank local group, and its **Invite by link** button appears only once its own answer is in, so a click can never race the read that would have found the link (#1038). ### Face-room human turns and UI polish (#166, #167) diff --git a/plugins/social/client.js b/plugins/social/client.js index ff2a7bca4..bb0961f6a 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1009,7 +1009,8 @@ window.__ModuleLoader__.load({ // 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. + // called live. The rule is the Members card's own (#876, `client.js:1201`); the card reads its answer + // inline and this helper is the strip's, so the two agree on the rule without sharing the code (#1038). 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 @@ -2165,6 +2166,9 @@ window.__ModuleLoader__.load({ // until the first turn, so a new group showed only its sidebar row. This strip stands in for that // header while — and only while — the chat is still blank: the group's name, who is in it, and the // way to invite someone. The first message ends it and the host's own header takes over. + // #998/#1019/#1038: the strip exists only for a blank local group. Hooks cannot sit behind that test + // (they must run in the same order on every render), so the test gates the work instead: a face room, a + // Bot chat and a chat with turns read nothing — no `room-invite` for a panel that would not draw it. function GroupStrip({ room, roster, blank } = {}) { useStyles() const roomId = room?.id @@ -2176,19 +2180,25 @@ window.__ModuleLoader__.load({ const [busy, setBusy] = useState(false) const [note, setNote] = useState(null) const [copied, setCopied] = useState(false) + // #1038: until this room's own answer is in, the panel does not know whether a link exists — and a + // button drawn now can be clicked before the read lands, minting the second ticket #1019 removed. + // `read` carries the room id, so switching rooms hides the button again in the same render. + const [read, setRead] = useState({ roomId: '', done: false }) const invite = shown.roomId === roomId ? shown.invite : null + const known = read.roomId === roomId && read.done // 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 + if (blank !== true || !roomId || room?.face) return undefined let gone = false host('room-invite', { roomId }).then((value) => { if (gone) return setShown({ roomId, invite: liveInviteOf(value) }) - }, () => {}) + setRead({ roomId, done: true }) + }, () => { if (!gone) setRead({ roomId, done: true }) }) return () => { gone = true } - }, [roomId]) + }, [blank, roomId, room?.face ? 'face' : '']) 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 ?? '')) @@ -2218,7 +2228,9 @@ window.__ModuleLoader__.load({ ? h(Fragment, null, h('code', { className: 'fs-invite-url', 'data-flybot': 'group-strip-url' }, invite.url), h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'group-strip-copy', onClick: copy }, copied ? tr('Copied') : tr('Copy link'))) - : h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'group-strip-invite', disabled: busy, onClick: mint }, tr('Invite by link'))), + : known + ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'group-strip-invite', disabled: busy, onClick: mint }, tr('Invite by link')) + : null), note ? h('div', { className: 'fs-hint', 'data-flybot': 'group-strip-note', role: 'status' }, note) : null) } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index d67cdde56..39f7eb997 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -6665,7 +6665,9 @@ test('#998: a zero-turn group draws its own strip above the composer, and drops const json = JSON.stringify(strip) assert.match(json, /Launch plan/, 'the group names itself') assert.match(json, /Chief, Writer/, 'and says who is in it') - assert.match(json, /Invite by link/, 'with the one way to add someone') + // The button is drawn from this room's read, so a first render without an answer has none (#1038); + // #1019 and #1038 pin the button itself. + assert.doesNotMatch(json, /Invite by link/, 'no invite button until the room answers') assert.match(json, /group-strip/) assert.equal(plugin.GroupStrip({ room, roster, blank: false }), null, 'the first message: the host header takes over, never two') assert.equal(plugin.GroupStrip({ room, roster }), null, 'a dock that is not told the chat is blank draws nothing') @@ -6749,6 +6751,56 @@ test('#1019: the group strip shows the room\'s live link and mints only when the assert.ok(button(panel), 'and the button is back for it') }) +// #1038: the strip's own read is the thing that decides whether a link exists, so the button cannot be +// drawn before that answer is in (a fast click could mint the second ticket #1019 removed), and a chat that +// draws no strip reads nothing at all. +test('#1038: the invite button waits for the room\'s answer, and a chat without a strip reads nothing', async () => { + const link = { ticket: 'LiveAbCdEfGhIjKlMnOpQr', handle: '@chief.alice.home', url: 'https://s/r/@chief.alice.home/LiveAbCdEfGhIjKlMnOpQr', expiresAt: null, server: true } + const calls = [] + const reads = [] + const host = sharingHost(async () => ({}), async (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'room-invite') return await new Promise(resolve => reads.push(() => resolve(payload.roomId === 'room-1' ? { invite: null, invites: [] } : { invite: link, invites: [link] }))) + if (endpoint === 'room-invite-mint') return { invite: { ...link, ticket: 'FreshAbCdEfGhIjKlMnOp' } } + return {} + }) + const mints = () => calls.filter(([endpoint]) => endpoint === 'room-invite-mint').length + const roster = { byId: { b1: { id: 'b1', name: 'Chief' } }, bots: [] } + const room = { id: 'room-1', name: 'Launch plan', members: ['b1'] } + // Nothing to read for a chat that draws no strip: a Bot chat, a face room, a chat with a turn. + for (const props of [ + { room, roster, blank: false }, + { room: { ...room, face: { status: 'open' } }, roster, blank: true }, + { room: undefined, roster, blank: true }, + ]) { + const panel = host.mount('GroupStrip', props) + await panel.settle() + assert.equal(panel.has('group-strip'), false, 'no strip is drawn') + } + assert.equal(reads.length, 0, 'and nothing was read') + assert.equal(mints(), 0, 'nor minted') + // The room with no live link: no button until its own answer is in, then the button. + let panel = host.mount('GroupStrip', { room, roster, blank: true }) + assert.equal(panel.has('group-strip'), true, 'the strip itself is up at once') + assert.equal(panel.has('group-strip-invite'), false, 'but the button waits for the answer') + reads.shift()() + await panel.settle() + assert.equal(panel.has('group-strip-invite'), true, 'no live link: inviting is the one action left') + assert.equal(mints(), 0, 'and reading never mints') + // A live link: still no button while the read is in flight, and none after it either. + panel = host.mount('GroupStrip', { room: { ...room, id: 'room-2' }, roster, blank: true }) + assert.equal(panel.has('group-strip-invite'), false, 'no button before the answer, even with a link waiting') + reads.shift()() + await panel.settle() + assert.equal(panel.nodes().find(n => n.props?.['data-flybot'] === 'group-strip-url')?.props.children.join(''), link.url, 'the live link is shown') + assert.equal(panel.has('group-strip-invite'), false, 'and there is still nothing to mint') + assert.equal(mints(), 0) + // Switching to a room whose answer has not arrived yet hides the button again in the same render. + reads.length = 0 + panel.update({ room: { ...room, id: 'room-3', name: 'Plan C' } }) + assert.equal(panel.has('group-strip-url'), false, 'the previous room\'s link is off screen') + assert.equal(panel.has('group-strip-invite'), false, 'and no button until this room answers') +}) 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 \}\)/)