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/1019-group-strip-reuses-live-link.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
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 **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)

Expand Down
40 changes: 36 additions & 4 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2149,20 +2163,38 @@ 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)
const mint = async () => {
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) }
}
Expand Down
106 changes: 100 additions & 6 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 \}\)/)
Expand Down Expand Up @@ -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 = {
Expand All @@ -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' })
Expand All @@ -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 {
Expand All @@ -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`)
Expand Down
Loading