Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1038-group-strip-waits-for-its-read.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 17 additions & 5 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ?? ''))
Expand Down Expand Up @@ -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)
}

Expand Down
54 changes: 53 additions & 1 deletion plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 \}\)/)
Expand Down
Loading