From 32fd6ba49b9edbca65d7d180240e53efd97f70b2 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:20:27 +0800 Subject: [PATCH] Face room lifecycle: unknown number opens no room, room stays open while 2+ people remain (#458) --- changelog.d/458-face-room-lifecycle.fixed.md | 1 + core/client.js | 14 +- core/src/client/sidebar.js | 7 +- core/src/client/text.js | 16 ++ core/test/face-row.test.js | 24 ++ docs/subsystems/core-client.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 20 ++ patches/ds-bot/0003-flybot-api-client.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 19 +- patches/ds-bot/files/test/face-row.test.js | 24 ++ plugins/social/client.js | 164 +++++++---- plugins/social/src/face-rooms.js | 21 +- plugins/social/src/social.js | 85 +++++- plugins/social/test/face-rooms.test.js | 19 ++ plugins/social/test/social.test.js | 286 +++++++++++++++---- 16 files changed, 587 insertions(+), 127 deletions(-) create mode 100644 changelog.d/458-face-room-lifecycle.fixed.md create mode 100644 core/test/face-row.test.js create mode 100644 patches/ds-bot/files/test/face-row.test.js diff --git a/changelog.d/458-face-room-lifecycle.fixed.md b/changelog.d/458-face-room-lifecycle.fixed.md new file mode 100644 index 00000000..945e47b1 --- /dev/null +++ b/changelog.d/458-face-room-lifecycle.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms: a number nobody is using no longer silently opens a room. Join says "Nobody is using 111111 yet." with Cancel and Start a room, and a room nobody else ever joined (left, or run out) leaves no Past rooms entry. A room stays open while two or more people are in it; when you are the last one, it reads "Room closed." with one Close button, and Leave (or Close) takes you back to the chat you were in without opening Bot settings. Both homes count down to Bot Social's one deadline, even when a device clock is off or a tab was in the background. A room's turns no longer take over its Bots' sidebar preview, the room row leads with its number ("482915 · Alice, Bob"), Enter chat steps aside while "Leave this room?" asks, Join waits for all 6 digits, and a click outside the dialog closes it like Escape ([#458](https://github.com/ThinkFlowLab/FlyBot/issues/458)). diff --git a/core/client.js b/core/client.js index 823ad1b4..39398aa6 100644 --- a/core/client.js +++ b/core/client.js @@ -2629,6 +2629,15 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); + if (named) return `${named[2]} · ${named[1]}`; + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; +} +function awayGroups(groups, roomsById) { + return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +} // src/client/sources.js function createSource(initial) { @@ -6399,7 +6408,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); const away = !running && groups.length > 0; const state = botState(running || away, doing, alert); - const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; + const shownAway = away ? awayGroups(groups, roster.roomsById) : []; + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; if (!isListedRemoteChat(bot) && !active && !main) return null; const onContextMenu = (event) => { event.preventDefault(); @@ -6557,7 +6567,7 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, wide ? (0, import_react10.createElement)( "span", { className: "bt-row-body" }, - (0, import_react10.createElement)("span", { className: "bt-row-name" }, (0, import_react10.createElement)("span", { className: "bt-row-title" }, title), face ? (0, import_react10.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react10.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react10.createElement)(PinMark) : null), + (0, import_react10.createElement)("span", { className: "bt-row-name" }, (0, import_react10.createElement)("span", { className: "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react10.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react10.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react10.createElement)(PinMark) : null), workingLine ? (0, import_react10.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react10.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, wide && waiting && !active ? (0, import_react10.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react10.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 0f7182b2..56f6151b 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us import { createPortal } from 'react-dom' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import { isMainOf } from './sources.js' -import { currentPart, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { awayGroups, currentPart, faceRowTitle, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -57,7 +57,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const groups = workKeys(useGroupWork(useWork, rooms => Object.entries(rooms).flatMap(([roomId, entries]) => entries.filter(entry => entry.botId === id).map(entry => `${roomId}|${entry.tool ?? ''}`)))) const away = !running && groups.length > 0 const state = botState(running || away, doing, alert) - const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t('a group chat'), groups[0].tool, groups.length - 1) : null + const shownAway = away ? awayGroups(groups, roster.roomsById) : [] + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t('a group chat'), shownAway[0].tool, shownAway.length - 1) : null if (!isListedRemoteChat(bot) && !active && !main) return null const onContextMenu = (event) => { event.preventDefault() @@ -173,7 +174,7 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess h(RoomAvatar, { room, roster, size: 36 }), pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), + h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) : wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null) diff --git a/core/src/client/text.js b/core/src/client/text.js index 1f40fcf4..5d059e22 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -295,3 +295,19 @@ export function usageOf(list, ids) { const hit = input > 0 ? Math.round((cacheRead / input) * 100) : 0 return t('{tokens} tok · Cache hit {hit}%', { tokens: formatTokens(input + output), hit }) } + +// A face room's sidebar row leads with its number, so a title cut to one line never hides it +// (#458): "Face-to-face · Alice, Bob · 482915" reads "482915 · Alice, Bob". The face mark says +// what the row is; the full title stays on hover. Any other title is kept as it is. +export function faceRowTitle(title) { + const text = String(title ?? '') + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/) + if (named) return `${named[2]} · ${named[1]}` + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text +} + +// The group chats whose work shows on a Bot's row as " · Thinking…". A face room has its +// own row, so its turns never take over the Bot's preview (#458). +export function awayGroups(groups, roomsById) { + return (groups ?? []).filter(entry => roomsById?.[entry.id]?.origin !== 'face-to-face') +} diff --git a/core/test/face-row.test.js b/core/test/face-row.test.js new file mode 100644 index 00000000..98f68864 --- /dev/null +++ b/core/test/face-row.test.js @@ -0,0 +1,24 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { awayGroups, faceRowTitle } from '../src/client/text.js' + +test('#458: a face-room sidebar row leads with its number, so a cut title never hides it', () => { + assert.equal(faceRowTitle('Face-to-face · SelfA0056, SelfB0056 · 482915'), '482915 · SelfA0056, SelfB0056') + assert.equal(faceRowTitle('Face-to-face · 482915'), '482915') + // A title without a number (an older room, the date form) or any other name stays as it is. + assert.equal(faceRowTitle('Face-to-face · Alice, Bob · Oct 18 16:27'), 'Face-to-face · Alice, Bob · Oct 18 16:27') + assert.equal(faceRowTitle('Kitchen'), 'Kitchen') + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) + assert.match(row, /className: 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) +}) + +test("#458: a face room's turns never take over its Bots' sidebar preview; other groups still show", () => { + const roomsById = { g1: { id: 'g1', origin: 'face-to-face', name: 'Face-to-face · A, B · 482915' }, g2: { id: 'g2', name: 'Team' } } + assert.deepEqual(awayGroups([{ id: 'g1', tool: 'think' }], roomsById), []) + assert.deepEqual(awayGroups([{ id: 'g1' }, { id: 'g2', tool: 'read' }], roomsById), [{ id: 'g2', tool: 'read' }]) + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) + assert.match(row, /const shownAway = away \? awayGroups\(groups, roster\.roomsById\) : \[\]/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 7e80c794..90f1967e 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -92,7 +92,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows the Bot's name (`bt-row-title`), then its owner (`bt-row-owner`, muted, shrinks first) and the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full `remoteBotTitle` ("Helper (Bob)") is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows the Bot's name (`bt-row-title`), then its owner (`bt-row-owner`, muted, shrinks first) and the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full `remoteBotTitle` ("Helper (Bob)") is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Approval wait badge on chat rows diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 5e68d06d..95bf7144 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -47,7 +47,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Invite to this Bot** is the grant the other way; joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181). -6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. +6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog shows "Nobody is using … yet." with **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) and **Start a room** (`start: true` adopts the held room through `GET`, no second create). A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line. 7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). 8. A friend's Bot's reply to a line you typed (or to any request) goes back through `bot.send` as an `auto-reply` and, unless Auto-send is on for that pair, is held for the replying Bot's owner (#254). The hold is decided on the reply itself (#418): the host tags the auto-reply with the chat and turn it answers (`meta.replyIn`, `turns.js` → `hooks.js` `replyRemote`), the Bot's row in `state` lists its `heldReplies` (id, status, turn, recipient; owner only, no text) and counts a waiting one in `pendingCount`, and that turn's bubble is drawn held (dashed, `core/src/client/held-reply.js`) with the decision on it (`flybot.approvals` inline entry `social-held-reply`: **Waiting for approval**, `Chief → SelfB1238`, **Approve** / **Reject** and the **Auto-send from now on** link, which approves it and turns on Auto-send for that pair only, the replying Bot → that person, through `social/auto-send { approvalId }` (#417), with the same toast as the dock card; it never touches the home-wide switch). No dock draws it, in either chat; the overlay's pointer leads to that chat. A reply that left without a card because Auto-send is on for that pair (#417) reads **Auto-sent to SelfB1238** there, quietly, so the owner sees it went without approval: the middleware's `allow` says `autoSent` (with the same `recipient`) and the host keeps that mark by turn (`state.flybot.autoSent`, last 200, no text). After the decision the same place reads **Sent to SelfB1238** or **Not sent** (muted; **Retry** on a failed send), and the list preview shows the friend's line instead of a reply that did not go. The card names the person who gets it: the holding middleware's `recipient` (the friend's owner name, stored as `recipientName`), or the friend's Bot by #384 naming when its chat is listed. A hold made before the tag (no `replyIn`) keeps its dock card as below. A friend's handle is never a room id. The sender sees nothing until it is approved. The reply also goes back to a requesting Bot that was not shared with us (the default one-way per-Bot grant): the replying Bot's grant to it is enough for a `reply`, never for a new request (#272). 9. Two holds, one click (#258): a send also held by Bot Social (`pending_approval`) is finished by the transport when the host says the owner approved *this very send* on a FlyBot card (`msg.approved`, set by `hooks.js` `release` only; a Bot-supplied field is overwritten). The approval id comes from the `send_as_member` answer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the same `confirm_code` step as a typed line. Room lines (`via: room` / `room-share`) and sends that left on Auto-send keep Bot Social's hold as before. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1f5c0c9f..2b67dedf 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1079,6 +1079,26 @@ diff --git a/src/client/text.js b/src/client/text.js } return '' } +@@ -230,3 +230,19 @@ export function usageOf(list, ids) { + const hit = input > 0 ? Math.round((cacheRead / input) * 100) : 0 + return t('{tokens} tok · Cache hit {hit}%', { tokens: formatTokens(input + output), hit }) + } ++ ++// A face room's sidebar row leads with its number, so a title cut to one line never hides it ++// (#458): "Face-to-face · Alice, Bob · 482915" reads "482915 · Alice, Bob". The face mark says ++// what the row is; the full title stays on hover. Any other title is kept as it is. ++export function faceRowTitle(title) { ++ const text = String(title ?? '') ++ const named = text.match(/^Face-to-face · (.+) · (\d{6})$/) ++ if (named) return `${named[2]} · ${named[1]}` ++ return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text ++} ++ ++// The group chats whose work shows on a Bot's row as " · Thinking…". A face room has its ++// own row, so its turns never take over the Bot's preview (#458). ++export function awayGroups(groups, roomsById) { ++ return (groups ?? []).filter(entry => roomsById?.[entry.id]?.origin !== 'face-to-face') ++} diff --git a/src/client/voice.js b/src/client/voice.js --- a/src/client/voice.js +++ b/src/client/voice.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 5d8a8030..28e68db7 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1633,7 +1633,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { isMainOf } from './sources.js' -import { currentPart, previewOf, relativeShort } from './text.js' -import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon } from './icons.js' -+import { currentPart, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' ++import { awayGroups, currentPart, faceRowTitle, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -1645,7 +1645,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { showsDshRow } from './dsh-offer.js' // ------------------------------------------------------------------------- -@@ -37,56 +37,117 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,56 +37,118 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -1671,8 +1671,10 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js const groups = workKeys(useGroupWork(useWork, rooms => Object.entries(rooms).flatMap(([roomId, entries]) => entries.filter(entry => entry.botId === id).map(entry => `${roomId}|${entry.tool ?? ''}`)))) const away = !running && groups.length > 0 const state = botState(running || away, doing, alert) - const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t('a group chat'), groups[0].tool, groups.length - 1) : null +- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t('a group chat'), groups[0].tool, groups.length - 1) : null - if (bot.hidden && !active && !main) return null ++ const shownAway = away ? awayGroups(groups, roster.roomsById) : [] ++ const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t('a group chat'), shownAway[0].tool, shownAway.length - 1) : null + if (!isListedRemoteChat(bot) && !active && !main) return null const onContextMenu = (event) => { event.preventDefault() @@ -1797,7 +1799,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, room.name), pinned ? h(PinMark) : null), -+ h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), ++ h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, face ? faceRowTitle(title) : title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, - wide && approval && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) + wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ec76c2aa..00ee2afb 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1696,7 +1696,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1595,38 +1595,204 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1595,38 +1595,213 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { count += entry.hidden ? 2 : 1; if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); } @@ -1737,6 +1737,15 @@ diff --git a/client.js b/client.js + const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; + return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); +} ++function faceRowTitle(title) { ++ const text = String(title ?? ""); ++ const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); ++ if (named) return `${named[2]} · ${named[1]}`; ++ return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; ++} ++function awayGroups(groups, roomsById) { ++ return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); ++} + +// src/client/sources.js +function createSource(initial) { @@ -4622,7 +4631,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4732,48 +4732,54 @@ function showsDshRow(dsh, app) { +@@ -4732,48 +4732,55 @@ function showsDshRow(dsh, app) { } // src/client/sidebar.js @@ -4667,8 +4676,10 @@ diff --git a/client.js b/client.js const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); const away = !running && groups.length > 0; const state = botState(running || away, doing, alert); - const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; +- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; - if (bot.hidden && !active && !main) return null; ++ const shownAway = away ? awayGroups(groups, roster.roomsById) : []; ++ const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; + if (!isListedRemoteChat(bot) && !active && !main) return null; const onContextMenu = (event) => { event.preventDefault(); @@ -4842,7 +4853,7 @@ diff --git a/client.js b/client.js { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), - workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react10.createElement)("span", { className: "bt-row-name" }, (0, import_react10.createElement)("span", { className: "bt-row-title" }, title), face ? (0, import_react10.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react10.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react10.createElement)(PinMark) : null), ++ (0, import_react10.createElement)("span", { className: "bt-row-name" }, (0, import_react10.createElement)("span", { className: "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react10.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react10.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react10.createElement)(PinMark) : null), + workingLine ? (0, import_react10.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react10.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null diff --git a/patches/ds-bot/files/test/face-row.test.js b/patches/ds-bot/files/test/face-row.test.js new file mode 100644 index 00000000..98f68864 --- /dev/null +++ b/patches/ds-bot/files/test/face-row.test.js @@ -0,0 +1,24 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { awayGroups, faceRowTitle } from '../src/client/text.js' + +test('#458: a face-room sidebar row leads with its number, so a cut title never hides it', () => { + assert.equal(faceRowTitle('Face-to-face · SelfA0056, SelfB0056 · 482915'), '482915 · SelfA0056, SelfB0056') + assert.equal(faceRowTitle('Face-to-face · 482915'), '482915') + // A title without a number (an older room, the date form) or any other name stays as it is. + assert.equal(faceRowTitle('Face-to-face · Alice, Bob · Oct 18 16:27'), 'Face-to-face · Alice, Bob · Oct 18 16:27') + assert.equal(faceRowTitle('Kitchen'), 'Kitchen') + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function RoomRow('), sidebar.indexOf('function AgentSessionRow(')) + assert.match(row, /className: 'bt-row-title' \}, face \? faceRowTitle\(title\) : title\)/) +}) + +test("#458: a face room's turns never take over its Bots' sidebar preview; other groups still show", () => { + const roomsById = { g1: { id: 'g1', origin: 'face-to-face', name: 'Face-to-face · A, B · 482915' }, g2: { id: 'g2', name: 'Team' } } + assert.deepEqual(awayGroups([{ id: 'g1', tool: 'think' }], roomsById), []) + assert.deepEqual(awayGroups([{ id: 'g1' }, { id: 'g2', tool: 'read' }], roomsById), [{ id: 'g2', tool: 'read' }]) + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) + assert.match(row, /const shownAway = away \? awayGroups\(groups, roster\.roomsById\) : \[\]/) +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index 1711b6a5..4ba713dd 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3,7 +3,7 @@ window.__ModuleLoader__.load({ id: '@flybot/social', factory(require) { - const { createElement: h, useEffect, useState, useRef } = require('react') + const { createElement: h, Fragment, useEffect, useState, useRef } = require('react') const slots = () => { // Same bootstrap as ds-bot's flybot-registry.js: whichever bundle loads first creates it. if (globalThis.__flybot__ === undefined) throw new Error('flybot-social needs ds-bot with FlyBot slots') @@ -101,7 +101,8 @@ window.__ModuleLoader__.load({ .fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff} .fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400} .fs-thinking{margin:0 12px 6px;padding-inline-start:8px;font-size:13px;line-height:20px;min-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.fs-room-end{margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;line-height:16px} +.fs-room-end{display:flex;align-items:center;gap:8px;margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;line-height:16px} +.fs-room-end-close{margin-left:auto;flex:none} /* A left or closed face room is read-only (#197): its end line replaces the composer. */ [class*="_composerStack"]:has([data-flybot="face-room-end"]) [class*="_root"]:has(> [data-composer-card]){display:none!important} .fs-me{display:flex;align-items:center;gap:6px;padding:0 10px 4px;justify-content:space-between;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36))} @@ -416,9 +417,14 @@ window.__ModuleLoader__.load({ } // Leave asks once (#182): leaving ends my part in the room for good. - function ConfirmLeave({ busy, onLeave, flybot = 'face-leave', size = 'fs-btn fs-btn-sm' } = {}) { + // `beside`: what sits next to Leave (Enter chat in a waiting room). It steps aside while + // "Leave this room?" asks, so the question never crowds a live button (#458). + function ConfirmLeave({ busy, onLeave, flybot = 'face-leave', size = 'fs-btn fs-btn-sm', beside = null } = {}) { const [ask, setAsk] = useState(false) - if (!ask) return h('button', { type: 'button', className: size, 'data-flybot': flybot, disabled: !!busy, onClick: () => setAsk(true) }, 'Leave') + if (!ask) { + const leave = h('button', { type: 'button', className: size, 'data-flybot': flybot, disabled: !!busy, onClick: () => setAsk(true) }, 'Leave') + return beside ? h(Fragment, null, leave, beside) : leave + } return h('span', { className: 'fs-leave-ask', 'data-flybot': 'face-leave-confirm' }, h('span', { className: 'fs-hint' }, 'Leave this room?'), h('button', { type: 'button', className: size, 'data-flybot': 'face-leave-yes', disabled: !!busy, onClick: () => { setAsk(false); onLeave?.() } }, 'Leave'), @@ -562,6 +568,21 @@ window.__ModuleLoader__.load({ || roster?.bots?.[0]?.id if (id && typeof actions?.openSession === 'function') actions.openSession(id) } + // The last chat open that was not a face room (every chat's dock tells it): Leave goes back + // there (#458), with the details panel closed so no Bot settings open on the way. + const faceNav = { last: '' } + function noteChat(sessionId, room) { + if (sessionId && room?.origin !== 'face-to-face') faceNav.last = String(sessionId) + } + function returnFromFace(roster, actions, roomId) { + actions?.closeOverlay?.() + const back = faceNav.last + const known = back && back !== roomId && (roster?.byId?.[back] || roster?.roomsById?.[back]) + if (known && typeof actions?.openSession === 'function') actions.openSession(back) + else openMainBot(roster, actions) + } + // Some member from another home: until then the room is you alone. + const othersIn = (members, mine) => (members ?? []).some(member => !mine(member)) // Separate Face-to-face room screen (#92): pick Bots, join by number, live waiting room. function FaceToFaceGroup({ actions, onClose, roster, remoteChats } = {}) { @@ -590,14 +611,20 @@ window.__ModuleLoader__.load({ }) }, () => {}) }, []) + // Closing on a number nobody uses lets that number go (#458); Escape, an outside click and + // Close all close the same way. + const dismiss = () => { + if (step === 'nobody' && room?.faceRoomId) void call('social/face-leave', { faceRoomId: room.faceRoomId }).catch(() => {}) + onClose?.() + } useEffect(() => { if (!onClose || typeof document?.addEventListener !== 'function') return undefined const onKey = (event) => { - if (event.key === 'Escape') { event.preventDefault(); onClose() } + if (event.key === 'Escape') { event.preventDefault(); dismiss() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [onClose]) + }, [onClose, step, room?.faceRoomId]) useEffect(() => { if (step !== 'waiting' || !(room?.faceRoomId || room?.roomId)) return undefined const faceId = room.faceRoomId @@ -615,10 +642,7 @@ window.__ModuleLoader__.load({ }, [step, room?.faceRoomId, room?.roomId]) useEffect(() => { if (step !== 'waiting' || room?.status !== 'candidate' || !room?.expiresAt) return undefined - if (typeof setInterval !== 'function') return undefined - const timer = setInterval(() => setTick(n => n + 1), 1000) - timer?.unref?.() - return () => clearInterval(timer) + return everySecond(() => setTick(n => n + 1)) }, [step, room?.status, room?.expiresAt]) useEffect(() => { if (step !== 'waiting' || room?.status !== 'open' || opened.current) return undefined @@ -629,7 +653,8 @@ window.__ModuleLoader__.load({ onClose?.() return undefined }, [step, room?.status, room?.roomId]) - const join = async (event) => { + // `start`: the owner chose to start a room with a number nobody uses yet (#458). + const join = async (event, { start = false } = {}) => { event?.preventDefault?.() const digits = String(code).replace(/\D/g, '').slice(0, 6) if (digits.length !== 6) { @@ -650,7 +675,14 @@ window.__ModuleLoader__.load({ for (const botId of selected) { const bot = bots.find(item => item.id === botId) try { - const result = await call('social/face-join', { code: digits, botId }) + const result = await call('social/face-join', { code: digits, botId, ...(start ? { start: true } : {}) }) + if (result?.nobody) { + // Nobody is in a room with this number: no room opens; the owner checks it or starts one. + setRoom({ nobody: true, faceRoomId: result.faceRoomId, joinCode: digits }) + setStep('nobody') + setBusy(false) + return + } last = result if (result?.joinedAs?.handle) { mine.push(result.joinedAs.handle) @@ -697,6 +729,12 @@ window.__ModuleLoader__.load({ setStatus({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } } + const cancelNobody = async () => { + const id = room?.faceRoomId + setRoom(null) + setStep('setup') + if (id) await call('social/face-leave', { faceRoomId: id }).catch(() => { /* the host lets a stale hold go on its next poll */ }) + } const leave = async () => { setBusy(true) setStatus(null) @@ -704,7 +742,7 @@ window.__ModuleLoader__.load({ if (room?.faceRoomId || room?.roomId) { await call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId }) } - openMainBot(roster, actions) + returnFromFace(roster, actions, room?.roomId) onClose?.() } catch (e) { setStatus({ tone: 'error', text: errorText(e) }) @@ -716,15 +754,26 @@ window.__ModuleLoader__.load({ const members = room?.members ?? [] const candidate = room?.status === 'candidate' const waiting = step === 'waiting' && room - return h('div', { className: 'fs-dialog-back', 'data-flybot': 'face-group', role: 'dialog', 'aria-label': 'Face-to-face room' }, + const digitsTyped = String(code).replace(/\D/g, '').length === 6 + const nobody = step === 'nobody' ? faceWaitCode(room, code) : '' + return h('div', { + className: 'fs-dialog-back', 'data-flybot': 'face-group', role: 'dialog', 'aria-label': 'Face-to-face room', + onClick: event => { if (event?.target === event?.currentTarget) dismiss() }, + }, h('div', { className: 'fs-dialog' }, h('div', { className: 'fs-dialog-head' }, h('div', { className: 'fs-name' }, 'Face-to-face room'), - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: () => onClose?.() }, 'Close')), - waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: dismiss }, 'Close')), + step === 'nobody' ? h('div', { className: 'fs-wait', 'data-flybot': 'face-nobody', role: 'status' }, + h('div', { className: 'fs-name' }, `Nobody is using ${nobody} yet.`), + h('div', { className: 'fs-hint' }, 'Check the number with the people you are meeting, or start a room with it and wait for them.'), + status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null, + h('div', { className: 'fs-wait-foot' }, + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-nobody-cancel', disabled: busy, onClick: cancelNobody }, 'Cancel'), + h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-nobody-start', disabled: busy, onClick: () => join(null, { start: true }) }, 'Start a room'))) : waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, h(FaceWaitCode, { code: faceWaitCode(room, code) }), h('div', { className: 'fs-hint fs-wait-count', 'data-flybot': 'face-wait-count', 'data-tick': tick }, candidate - ? (room.expiresAt ? countdown(room.expiresAt) : 'Anyone with the number can still join.') + ? (room.expiresAt ? countdown(room.expiresAt, room.clockOffsetMs) : 'Anyone with the number can still join.') : 'The room is open. The number is no longer valid.'), h('div', { className: 'fs-wait-body' }, h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, @@ -735,16 +784,18 @@ window.__ModuleLoader__.load({ handle: member.handle, mine: memberIsMine(member, bots, room), }))), - members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null, + othersIn(members, member => memberIsMine(member, bots, room)) ? null : h('div', { className: 'fs-hint', 'data-flybot': 'face-wait-alone' }, 'Waiting for others to join…'), failures.length ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' }, failures.map(item => `${item.name}: ${item.text}`).join(' ')) : null, status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null), h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-wait-foot' }, - h(ConfirmLeave, { busy, onLeave: leave, size: 'fs-btn' }), - h('button', { - type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', - disabled: !!busy || !room.roomId, onClick: enter, - }, 'Enter chat'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' }, + h(ConfirmLeave, { + busy, onLeave: leave, size: 'fs-btn', + beside: h('button', { + type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', + disabled: !!busy || !room.roomId, onClick: enter, + }, 'Enter chat'), + }))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' }, h('div', { className: 'fs-hint' }, 'Pick your Bots and enter the same 6-digit number as everyone nearby.'), h('ul', { className: 'fs-tiles fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' }, bots.map(bot => h(FaceTile, { @@ -766,9 +817,9 @@ window.__ModuleLoader__.load({ }), h('button', { type: 'submit', className: 'fs-btn', - // Enabled once a Bot is picked: a short number answers with the inline hint, never a silent grey button (#306). - disabled: busy || !selected.length, - title: selected.length ? undefined : 'Pick a Bot first.', + // Enabled once a Bot is picked and all 6 digits are in (#458); the tooltip says what is missing. + disabled: busy || !selected.length || !digitsTyped, + title: !selected.length ? 'Pick a Bot first.' : digitsTyped ? undefined : 'Enter all 6 digits.', }, 'Join')), !selected.length && bots.length ? h('div', { className: 'fs-hint', 'data-flybot': 'face-join-pick' }, 'Pick a Bot first.') : null, failures.length ? h('div', { className: 'fs-status fs-fail', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' }, @@ -1095,12 +1146,17 @@ window.__ModuleLoader__.load({ const names = [...new Set(gone)] // The one who left reads that they left; the others read who left (#304). if (status === 'left') return 'You left this room.' - if (names.length > 0) return `${names.join(', ')} left the room. This room is closed.` - return 'This room is closed.' + if (names.length > 0) return `${names.join(', ')} left the room. Room closed.` + return 'Room closed.' } - function FaceRoomStatus({ room } = {}) { + // A room that closed around you is no dead end (#458): one Close goes back to where you were. + function FaceRoomStatus({ room, sessionId, roster, actions } = {}) { + noteChat(sessionId, room) const text = faceEndLine(room) - return text ? h('div', { className: 'fs-room-end', 'data-flybot': 'face-room-end', role: 'status' }, text) : null + if (!text) return null + const closed = room?.face?.status === 'closed' + return h('div', { className: 'fs-room-end', 'data-flybot': 'face-room-end', role: 'status' }, text, + closed ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm fs-room-end-close', 'data-flybot': 'face-room-close', onClick: () => returnFromFace(roster, actions, room.id) }, 'Close') : null) } @@ -1303,8 +1359,10 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', role: 'menuitem', onClick: () => { setMenu(false); onRemove?.(bot) } }, 'Remove this Bot')) : null) } - function countdown(expiresAt) { - const at = Date.parse(expiresAt) + // Every home counts down to Bot Social's one deadline (#458): `offsetMs` is how far the server's + // clock runs ahead of this one (0 unless the skew is real). + function countdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) if (!Number.isFinite(at)) return 'Anyone with the number can still join' const left = Math.max(0, at - Date.now()) if (left <= 0) return 'Joining has ended' @@ -1312,6 +1370,17 @@ window.__ModuleLoader__.load({ const total = Math.max(1, Math.ceil(left / 1000)) return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` } + // A one-second tick that also catches up the moment the page is shown again, so a tab that + // sat in the background never shows a stale countdown (#458). + function everySecond(fn) { + if (typeof setInterval !== 'function') return undefined + const timer = setInterval(fn, 1000) + timer?.unref?.() + const onShow = () => { if (typeof document === 'undefined' || !document.hidden) fn() } + const doc = typeof document !== 'undefined' && typeof document.addEventListener === 'function' ? document : null + doc?.addEventListener('visibilitychange', onShow) + return () => { clearInterval(timer); doc?.removeEventListener('visibilitychange', onShow) } + } const isPastFace = (room) => { if (!room) return false @@ -1404,10 +1473,7 @@ window.__ModuleLoader__.load({ useEffect(() => { const expiresAt = snapshot?.expiresAt || face?.expiresAt if (!candidate || !expiresAt) return undefined - if (typeof setInterval !== 'function') return undefined - const timer = setInterval(() => setTick(n => n + 1), 1000) - timer?.unref?.() - return () => clearInterval(timer) + return everySecond(() => setTick(n => n + 1)) }, [candidate, snapshot?.expiresAt, face?.expiresAt]) const run = async (label, fn) => { setBusy(label) @@ -1425,7 +1491,7 @@ window.__ModuleLoader__.load({ return h('div', { className: 'fs-wait-overview', 'data-flybot': 'face-overview', 'data-wait': true }, h(FaceWaitCode, { code: faceWaitCode(snapshot || face || room) }), h('div', { className: 'fs-hint fs-wait-count', 'data-tick': tick, 'data-flybot': 'face-wait-count' }, - expiresAt ? countdown(expiresAt) : 'Anyone with the number can still join.'), + expiresAt ? countdown(expiresAt, snapshot?.clockOffsetMs) : 'Anyone with the number can still join.'), h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, members.map(member => h(FaceTile, { key: member.handle || member.id, @@ -1437,23 +1503,23 @@ window.__ModuleLoader__.load({ handle: member.handle, mine: mineMember(member), }))), - members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null, + othersIn(members, mineMember) ? null : h('div', { className: 'fs-hint', 'data-flybot': 'face-wait-alone' }, 'Waiting for others to join…'), h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-wait-foot' }, h(ConfirmLeave, { busy, size: 'fs-btn', onLeave: () => run('leave', async () => { await call('social/face-leave', { roomId: room.id, faceRoomId: face?.roomId }) - openMainBot(roster, actions) - }), - }), - h('button', { - type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', disabled: !!busy, - onClick: () => run('start', async () => { - await call('social/face-start', { roomId: room.id, faceRoomId: face?.roomId }) - if (room.id && typeof actions?.openSession === 'function') actions.openSession(room.id) + returnFromFace(roster, actions, room.id) }), - }, 'Enter chat')), + beside: h('button', { + type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', disabled: !!busy, + onClick: () => run('start', async () => { + await call('social/face-start', { roomId: room.id, faceRoomId: face?.roomId }) + if (room.id && typeof actions?.openSession === 'function') actions.openSession(room.id) + }), + }, 'Enter chat'), + })), note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } const mineNames = members.filter(mineMember).map(member => member.displayName || member.name).filter(Boolean) @@ -1473,8 +1539,8 @@ window.__ModuleLoader__.load({ busy, onLeave: () => run('leave', async () => { await call('social/face-leave', { roomId: room.id, faceRoomId: face?.roomId }) - // Straight to the main Bot, not a blank pane while the room closes. - openMainBot(roster, actions) + // Back to where you were, not a blank pane while the room closes. + returnFromFace(roster, actions, room.id) }), }))), h(FaceAutoSendControl, { bot: mineNames[0] }), diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 143b100c..b31f00a4 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -79,8 +79,19 @@ export function formatRetryAfter(seconds) { return `${m} minute${m === 1 ? '' : 's'}` } -export function countdownLabel(expiresAt, now = Date.now()) { - const at = Date.parse(expiresAt) +// How far Bot Social's clock runs ahead of ours, from a response's `Date` header (#458). The +// header has whole seconds, so a small gap is noise: only a real skew (3 s or more) counts, and +// then every home counts down to the same server deadline whatever its own clock says. +export const CLOCK_SKEW_MIN_MS = 3000 +export function serverClockOffset(dateHeader, sentAt, receivedAt) { + const server = Date.parse(String(dateHeader ?? '')) + if (!Number.isFinite(server) || !Number.isFinite(sentAt) || !Number.isFinite(receivedAt)) return undefined + const offset = server + 500 - (sentAt + receivedAt) / 2 + return Math.abs(offset) >= CLOCK_SKEW_MIN_MS ? Math.round(offset) : 0 +} + +export function countdownLabel(expiresAt, now = Date.now(), offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) if (!Number.isFinite(at)) return '' const left = Math.max(0, at - now) if (left <= 0) return 'Joining has ended' @@ -534,11 +545,13 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc if (!token) throw new Error('flybot-social: owner connector token is required') const origin = String(server).replace(/\/+$/, '') const base = `${origin}/v1/face-rooms` + let clockOffsetMs = 0 async function callHttp(method, url, body) { const signal = AbortSignal.timeout(timeoutMs) let response let data + const sentAt = Date.now() try { response = await fetchImpl(url, { method, @@ -550,6 +563,8 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc body: body !== undefined ? JSON.stringify(body) : undefined, signal, }) + const offset = serverClockOffset(response.headers?.get?.('date'), sentAt, Date.now()) + if (offset !== undefined) clockOffsetMs = offset const text = await response.text() try { data = text ? JSON.parse(text) : {} } catch { data = {} } } catch (error) { @@ -568,6 +583,8 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc } return { + // Bot Social's clock minus ours, as last seen (0 unless the skew is real). + clockOffsetMs: () => clockOffsetMs, async join({ code, bots } = {}) { const digits = String(code ?? '').trim() if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 0401d09a..cea87239 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2515,6 +2515,25 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (!faceApi) throw new Error('Bot Social is not connected') return faceApi } + // A number nobody else is using opens no room here (#458). Bot Social answers a join of an unknown + // number with a fresh candidate, so it is held here, off the sidebar and Past rooms, until the owner + // starts a room with it (it becomes that room) or cancels (it is left at once). Abandoned holds go + // on the next poll. + const unclaimed = new Map() + const UNCLAIMED_MS = 2 * 60_000 + const faceClockOffset = () => Number(faceApi?.clockOffsetMs?.()) || 0 + async function dropUnclaimed(serverId) { + const held = unclaimed.get(serverId) + if (!held) return undefined + unclaimed.delete(serverId) + try { + await faceNeed().leave(serverId, { bots: [held.handle] }) + } catch (error) { + if (!alreadyLeftFace(error)) warn('leaving an unused face-room number failed', error) + } + if (!held.hadIdentity) await releaseRoomOnlyIdentity(held.botId) + return { faceRoomId: serverId, status: 'cancelled' } + } const localFaceBots = () => flybot.bot.list().map(bot => { const member = memberOf(bot.id) return { @@ -2662,8 +2681,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle))) // Every other home left: the room has ended here, but this home did not leave it (#304). if (status === 'open' && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true } + // The joining window ran out and nobody else ever came (#458): the room ends, it is no open chat with yourself. + const started = Date.parse(snapshot?.startedAt || '') + const deadline = Date.parse(expiresAt || '') + if (status === 'open' && currentRemotes.length === 0 && neverShared(hosted) && Number.isFinite(started) && Number.isFinite(deadline) && started >= deadline) { + return { status: 'closed', peersGone: true } + } return undefined } + // Nobody from another home was ever in this room (#458): it was no meeting, so it leaves no Past + // rooms entry and no kept transcript. + function neverShared(hosted) { + return !(hosted?.participants ?? []).some(p => p.remote === true) + } async function applyFaceSnapshot(snapshot, { asBot } = {}) { if (!snapshot?.roomId || !flybot.rooms?.upsertFace) return undefined const attested = await serverAttests() @@ -2767,7 +2797,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp void flybot.rooms.postNotice?.(room.id, text) } settings.faceRooms[snapshot.roomId].seen = seen - if (past) { + const lone = Boolean(past) && neverShared(flybot.rooms.ofFace?.(snapshot.roomId) ?? room) + if (past && !lone) { rememberPastFace(viewHostedFace(flybot.rooms.ofFace?.(snapshot.roomId) ?? room, { faceRoomId: snapshot.roomId, localRoomId: room.id, @@ -2779,7 +2810,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp armSocialPoll() // The room ended here because everyone else left: leave it on Bot Social too, so this // home stops polling it and a room-only identity is released (social.md invariant 4, #304). - if (past?.peersGone && settings.faceRooms[snapshot.roomId]) { + if ((past?.peersGone || lone) && settings.faceRooms[snapshot.roomId]) { await leaveFaceRoom({ faceRoomId: snapshot.roomId, status: 'closed' }) .catch(error => warn('face-room leave after the last peer left failed', error)) } @@ -2798,7 +2829,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp facePolling = (async () => { let n = 0 try { - const listed = await faceApi.list() + for (const [serverId, held] of [...unclaimed]) { + if (now() - held.at >= UNCLAIMED_MS) await dropUnclaimed(serverId) + } + const listed = (await faceApi.list()).filter(item => !unclaimed.has(item.roomId)) const known = new Set(listed.map(item => item.roomId)) const got = await mapPool(listed, GRANT_CONCURRENCY, async (snapshot) => { try { @@ -2826,7 +2860,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp for (const [serverId, meta] of Object.entries(settings.faceRooms)) { if (known.has(serverId)) continue const hosted = flybot.rooms.ofFace?.(serverId) - rememberPastFace(viewHostedFace(hosted, { faceRoomId: serverId, localRoomId: meta?.localRoomId, status: 'closed', joinCode: meta?.joinCode }) + if (hosted?.id && neverShared(hosted)) void Promise.resolve(flybot.rooms.dropFace?.(hosted.id)).catch(() => {}) + else rememberPastFace(viewHostedFace(hosted, { faceRoomId: serverId, localRoomId: meta?.localRoomId, status: 'closed', joinCode: meta?.joinCode }) || { faceRoomId: serverId, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'closed', members: [], joinCode: meta?.joinCode }) delete settings.faceRooms[serverId] dropRoomRemotes(serverId) @@ -2843,17 +2878,32 @@ export function createSocial(service, config = {}, { client: given, api: givenAp })().finally(() => { facePolling = undefined }) return facePolling } - async function joinFaceRoom({ code, botId } = {}) { + // `start`: the owner chose to start a room with this number. Without it, a number nobody else is + // using answers `nobody` and opens no room (#458). + async function joinFaceRoom({ code, botId, start = false } = {}) { const digits = String(code ?? '').trim() if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) const bot = pickJoiner(botId, { needHandle: false }) if (!bot) throw new Error('Choose which of your Bots joins') const local = flybot.bot.get(bot.id) - const hadIdentity = Boolean(memberOf(bot.id)) + const held = [...unclaimed].find(([, item]) => item.code === digits && item.botId === bot.id) + const hadIdentity = held ? held[1].hadIdentity : Boolean(memberOf(bot.id)) try { await registerForFace(local) const ready = pickJoiner(bot.id) - const snapshot = await faceNeed().join({ code: digits, bots: [ready.handle] }) + let snapshot + if (held) { + // Starting the room this number already holds: no second room on Bot Social. + unclaimed.delete(held[0]) + snapshot = await faceNeed().get(held[0], { as: ready.handle }).catch(() => undefined) + } + snapshot ??= await faceNeed().join({ code: digits, bots: [ready.handle] }) + const locals = new Set(bringable().map(item => item.handle).filter(Boolean)) + const others = (snapshot.members ?? []).some(member => member.handle && !locals.has(norm(member.handle))) + if (start !== true && !others && !settings.faceRooms[snapshot.roomId]) { + unclaimed.set(snapshot.roomId, { code: digits, botId: ready.id, handle: ready.handle, hadIdentity, at: now() }) + return { nobody: true, faceRoomId: snapshot.roomId, joinCode: digits } + } const prev = settings.faceRooms[snapshot.roomId] settings.faceRooms[snapshot.roomId] = rememberAsBot({ ...(prev ?? {}), @@ -2862,8 +2912,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const room = await applyFaceSnapshot(snapshot, { asBot: ready.handle }) saveSettings() if (room) await setRoomGrant(ready.id, room, { faceRoomId: snapshot.roomId, name: faceRoomTitle(snapshot, { code: digits, room }) }) - return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle }, joinCode: digits }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members, joinCode: digits } + return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle }, joinCode: digits }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members, joinCode: digits, clockOffsetMs: faceClockOffset() } } catch (error) { + // A held number that could not become a room is let go on Bot Social too. + if (held && !settings.faceRooms[held[0]]) await faceNeed().leave(held[0], { bots: [held[1].handle] }).catch(() => { /* the join error below is what the owner sees */ }) // A brand-new room-only identity that never made it into a room must not block retry // (and must not linger as a reachable home member). if (!hadIdentity) await releaseRoomOnlyIdentity(bot.id) @@ -2890,14 +2942,18 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } } async function leaveFaceRoom({ roomId, faceRoomId, bots, status: ended } = {}) { + if (faceRoomId && unclaimed.has(faceRoomId)) return dropUnclaimed(faceRoomId) const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] if (!id) throw new Error('which room?') // `closed` when the room ended around us (every peer left), `left` when the owner left it. const status = ended === 'closed' ? 'closed' : 'left' const meta = settings.faceRooms[id] const hosted = flybot.rooms.ofFace?.(id) - rememberPastFace(viewHostedFace(hosted, { faceRoomId: id, localRoomId: meta?.localRoomId, status, joinCode: meta?.joinCode }) - || { faceRoomId: id, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status, members: [], joinCode: meta?.joinCode }) + const lone = Boolean(hosted?.id) && neverShared(hosted) + if (!lone) { + rememberPastFace(viewHostedFace(hosted, { faceRoomId: id, localRoomId: meta?.localRoomId, status, joinCode: meta?.joinCode }) + || { faceRoomId: id, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status, members: [], joinCode: meta?.joinCode }) + } const handles = (Array.isArray(bots) && bots.length ? bots : asBotsOf(meta)).map(norm).filter(Boolean) let result try { @@ -2915,7 +2971,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } // The room stays as a read-only past room with its transcript (#192): Open from Past rooms // finds it already hosted. It is only marked left; the sidebar lists past rooms elsewhere. - await flybot.rooms.upsertFace?.({ roomId: id, status, ...(meta?.joinCode ? { joinCode: meta.joinCode } : {}) }).catch(() => {}) + // A room nobody else ever joined is dropped instead (#458). + if (lone) await Promise.resolve(flybot.rooms.dropFace?.(hosted.id)).catch(() => {}) + else await flybot.rooms.upsertFace?.({ roomId: id, status, ...(meta?.joinCode ? { joinCode: meta.joinCode } : {}) }).catch(() => { /* the leave itself went through */ }) } delete settings.faceRooms[id] dropRoomRemotes(id) @@ -3016,15 +3074,16 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (!room?.id) throw new Error('Could not open that room') return { roomId: room.id, faceRoomId: serverId } } - disposers.push(flybot.api.handle('social/face-join', async ({ code, botId } = {}) => joinFaceRoom({ code, botId }))) + disposers.push(flybot.api.handle('social/face-join', async ({ code, botId, start } = {}) => joinFaceRoom({ code, botId, start: start === true }))) disposers.push(flybot.api.handle('social/face-open', async (payload) => openFaceRoom(payload), { read: true })) disposers.push(flybot.api.handle('social/face-rooms', async (payload) => { if (payload?.poll !== false) await pollFaceRooms().catch(() => { /* a failed poll shows the rooms as last polled */ }) const pastById = new Map((settings.pastFaceRooms ?? []).filter(item => item?.faceRoomId).map(item => [item.faceRoomId, item])) + const clockOffsetMs = faceClockOffset() const rooms = Object.entries(settings.faceRooms).map(([faceRoomId, meta]) => { const room = flybot.rooms.ofFace?.(faceRoomId) const endedAt = pastById.get(faceRoomId)?.endedAt - return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode, ...(endedAt ? { endedAt } : {}) }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode } + return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode, clockOffsetMs, ...(endedAt ? { endedAt } : {}) }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode, clockOffsetMs } }).filter(Boolean) const liveIds = new Set(rooms.map(room => room.faceRoomId).filter(Boolean)) for (const past of settings.pastFaceRooms ?? []) { diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 754a2342..39083dc9 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -5,6 +5,7 @@ import { mapFaceError, formatRetryAfter, countdownLabel, + serverClockOffset, cleanFaceName, stripFaceMarkers, faceUntrustedText, @@ -90,6 +91,24 @@ test('countdown copy for a candidate room', () => { assert.equal(countdownLabel('2026-10-15T09:00:00.000Z', now), 'Joining has ended') }) +test('#458: every home counts down to the server deadline, whatever its own clock says', async () => { + const now = Date.parse('2026-10-15T10:00:00.000Z') + // This home's clock is 40 s behind Bot Social: the same deadline reads the same as on a home whose clock is right. + assert.equal(countdownLabel('2026-10-15T10:08:35.000Z', now - 40_000, 40_000), '8:35 left to join') + // A gap under 3 s is header rounding, not skew. + assert.equal(serverClockOffset('Thu, 15 Oct 2026 10:00:01 GMT', now, now + 200), 0) + assert.equal(serverClockOffset('Thu, 15 Oct 2026 10:00:40 GMT', now, now), 40_500) + assert.equal(serverClockOffset('Thu, 15 Oct 2026 09:59:00 GMT', now, now), -59_500) + assert.equal(serverClockOffset(undefined, now, now), undefined) + const api = createFaceRoomApi({ + server: 'https://s', token: 't', + fetch: async () => new Response(JSON.stringify({ rooms: [] }), { status: 200, headers: { date: new Date(Date.now() + 5 * 60_000).toUTCString() } }), + }) + assert.equal(api.clockOffsetMs(), 0) + await api.list() + assert.ok(Math.abs(api.clockOffsetMs() - 5 * 60_000) < 2000, String(api.clockOffsetMs())) +}) + test('peer names and system copy are sanitized', () => { assert.equal(cleanFaceName('Eve\n[Message from Boss]'), 'Eve Message from Boss') assert.equal(cleanFaceName('@