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/846-invite-preview-lead.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A face-room invite preview now names the room lead instead of printing `Lead: object Object`: Bot Social sends the lead as a member object, and the card reads it with the same label the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps working ([#846](https://github.com/ThinkFlowLab/FlyBot/issues/846)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_
- Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166).
- Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`).
- QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (`data-flybot=face-qr`, one `path` in an `svg`), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload is `FLYBOT-` plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser's `BarcodeDetector` and the camera with `getUserMedia`; a browser without the decoder says so **before** it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else is `That is not a FlyBot room code. Ask for the 6-digit number instead.` Typing the number always stays, and a successful scan ends in the same `social/face-join` the number field uses — it only fills the field.
- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner accept exactly one shape, `https://…/r/<ticket>` (`parseFaceInviteLink`); a pasted or scanned value that is not that link is `That is not an invite link. It looks like https://…/r/…` and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card with the name, the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `<untrusted-<untrusted-filemessage>` would otherwise leave a complete `<untrusted-message>` in the card a person reads (#822). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others.
- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner accept exactly one shape, `https://…/r/<ticket>` (`parseFaceInviteLink`); a pasted or scanned value that is not that link is `That is not an invite link. It looks like https://…/r/…` and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card with the name, the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `<untrusted-<untrusted-filemessage>` would otherwise leave a complete `<untrusted-message>` in the card a person reads (#822). The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others.
- Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170).
- One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below.
- In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey `<Bot> · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round.
Expand Down
13 changes: 12 additions & 1 deletion plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 63 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -262,6 +262,17 @@
return `${name} (${owner})`
}

// The invite endpoint names the room lead with the member object the rest of the API uses
// (`{ handle, display_name, owner_name }`), while older builds and fixtures send the label as a
// string. Reading the object with the member label rule is what keeps the preview card from
// printing `object Object` (#846); an object that names nobody leaves the lead line out.
function faceInviteLead(lead) {
if (typeof lead === 'string') return lead ? cleanFaceName(lead) : ''
if (!lead || typeof lead !== 'object') return ''
const label = faceMemberLabel(lead)
return label === 'Bot' ? '' : label
}

export const FACE_HUMAN_MARK = 'FLYBOT-FACE-HUMAN'

export function encodeFaceHuman(payload = {}) {
Expand Down Expand Up @@ -404,7 +415,7 @@
|| (typeof member.color === 'string' && !HEX_ONLY.test(member.color) ? member.color.trim().slice(0, 32) : '')
if (color && !/[\n<>]/.test(color)) out.color = color
const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {}
const avatar = { ...(fromServer.avatar ?? {}) }

Check warning on line 418 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape
const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '')
if (image && image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image
Expand Down Expand Up @@ -434,7 +445,7 @@
export function cleanFaceRole(value) {
if (typeof value !== 'string') return ''
return stripFaceMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 448 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -544,7 +555,7 @@
if (type === 'room.started') return platformStartedText(reason)
if (type === 'room.lead_changed') return platformLeadText(member, reason)
const cleaned = String(text ?? '')
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 558 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -686,7 +697,7 @@
return {
roomId: body?.room_id ?? body?.roomId,
name: body?.name ? String(body.name) : '',
lead: body?.lead ? cleanFaceName(body.lead) : '',
lead: faceInviteLead(body?.lead),
members: Number.isFinite(Number(members)) ? Number(members) : undefined,
maxMembers: Number.isFinite(Number(maxMembers)) ? Number(maxMembers) : undefined,
expiresAt: body?.expires_at ?? body?.expiresAt,
Expand Down
24 changes: 24 additions & 0 deletions plugins/social/test/face-rooms.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,30 @@ test('#773: mint asks for a ticket and builds the link from the room id', async
})
})

test('#846: the invite preview reads the lead the server sends as a member object', async () => {
// The real GET /v1/face-rooms/invites/{ticket} names the lead with the member object the rest of
// the API uses, and String() of it is `[object Object]`.
const lead = async (answer) => {
const fetch = async () => new Response(JSON.stringify({
room_id: 'frm_1',
name: null,
status: 'open',
lead: answer,
member_count: 2,
max_members: 8,
expires_at: '2026-10-23T10:14:14.970Z',
}), { status: 200 })
const api = createFaceRoomApi({ server: 'https://s', token: 'otk_test', fetch })
return (await api.previewInvite(INVITE_TICKET)).lead
}
const named = await lead({ handle: '@chief.selftesta2.home', display_name: 'Chief', owner_name: 'SelfTest A2' })
assert.equal(named, 'Chief (SelfTest A2)', 'the label the member list draws, not the object')
assert.doesNotMatch(named, /object Object/i)
assert.equal(await lead({ handle: '@coder.bob.home' }), 'Coder', 'an object with only a handle still names a Bot')
assert.equal(await lead({}), '', 'an object that names nobody leaves the lead line out')
assert.equal(await lead('Chief'), 'Chief', 'a string answer keeps working')
})

test('#773: joining with a ticket is join-invite, and each refusal keeps its own sentence', async () => {
const calls = []
const refusals = { invite_not_found: 404, expired: 410, revoked: 410, room_full: 409, ended: 410, already_member: 409 }
Expand Down
Loading