diff --git a/changelog.d/117-face-room-two-instance.fixed.md b/changelog.d/117-face-room-two-instance.fixed.md new file mode 100644 index 000000000..2d66ca164 --- /dev/null +++ b/changelog.d/117-face-room-two-instance.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms keep a compact Friends' Bots row (short open/joining chip so the name and Start fit), drop the empty header after Leave, label waiting tiles with the owner (`Chief · BoxB`), revive a rejoined Bot instead of duplicating it and still post a join line when the other side's first event is missing, refresh the rooms card and Friends' Bots without a reload, show member avatars on the sidebar row and chat header, and fan an owner's typed line to the other instance as a human message in that room. ([#117](https://github.com/ThinkFlowLab/FlyBot/issues/117)) diff --git a/core/client.js b/core/client.js index ba516a314..5b2a6e3d0 100644 --- a/core/client.js +++ b/core/client.js @@ -1728,9 +1728,41 @@ function clusterSeats(count, frame) { const half = frame / 2; return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; } +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); + const remotes = []; + for (const p of room2?.participants ?? []) { + if (p.leftSeq !== void 0) continue; + if (p.kind === "human") continue; + if (!p.remote && p.id && seen.has(p.id)) continue; + if (!p.remote && p.id && roster?.byId?.[p.id]) { + if (!seen.has(p.id)) { + members.push(roster.byId[p.id]); + seen.add(p.id); + } + continue; + } + const handle = p.handle || (p.remote ? p.id : ""); + if (!handle) continue; + if (members.some((bot) => bot.remote?.handle === handle || bot.handle === handle)) continue; + const key = `h:${handle}`; + if (seen.has(key)) continue; + seen.add(key); + remotes.push(lookBotForRemote({ + handle, + displayName: p.displayName, + name: p.displayName, + label: p.label, + color: p.color, + avatar: p.avatar + }, roster)); + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; +} function RoomAvatar({ room: room2, roster, size = 36 }) { - const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const people = members.length === 1 ? [members[0], null] : members; + const people = roomAvatarPeople(room2, roster); const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); if (people.length === 0) return (0, import_react.createElement)(BotMark, { size }); if (size <= 20 && people.length > 1) { diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 82d57521d..1e54ba70a 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -1,5 +1,5 @@ import { createElement as h, useEffect, useRef, useState } from 'react' -import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' +import { fnv1a, lookOf, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' import { t } from './i18n.js' export { colorOf, lookOf, remoteSeed, lookBotForRemote } from './characters.js' @@ -130,10 +130,42 @@ function clusterSeats(count, frame) { return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]] } +export function roomAvatarPeople(room, roster) { + const members = (room?.members ?? []).map(id => roster?.byId?.[id]).filter(Boolean) + const seen = new Set(members.map(bot => bot.id)) + const remotes = [] + for (const p of room?.participants ?? []) { + if (p.leftSeq !== undefined) continue + if (p.kind === 'human') continue + if (!p.remote && p.id && seen.has(p.id)) continue + if (!p.remote && p.id && roster?.byId?.[p.id]) { + if (!seen.has(p.id)) { + members.push(roster.byId[p.id]) + seen.add(p.id) + } + continue + } + const handle = p.handle || (p.remote ? p.id : '') + if (!handle) continue + if (members.some(bot => bot.remote?.handle === handle || bot.handle === handle)) continue + const key = `h:${handle}` + if (seen.has(key)) continue + seen.add(key) + remotes.push(lookBotForRemote({ + handle, + displayName: p.displayName, + name: p.displayName, + label: p.label, + color: p.color, + avatar: p.avatar, + }, roster)) + } + if (members.length === 1 && remotes.length === 0) return [members[0], null] + return [...members, ...remotes] +} + export function RoomAvatar({ room, roster, size = 36 }) { - const members = room.members.map(id => roster.byId[id]).filter(Boolean) - // A group with one Bot left shows the user beside it. - const people = members.length === 1 ? [members[0], null] : members + const people = roomAvatarPeople(room, roster) const seat = (bot, seatSize, key) => (bot ? h(BotMark, { key, bot, size: seatSize }) : h('span', { key, className: 'bt-cluster-you', style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } })) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 09140204c..e1ad656f2 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -185,6 +185,14 @@ export function isFaceRoom(room) { return room?.origin === 'face-to-face' } +function faceStatusFrom(spec, current) { + const status = spec?.status + if (status === 'open' || status === 'closed' || status === 'left') return status + if (spec?.startedAt) return 'open' + if (status) return status + return current || 'candidate' +} + export function presentRoom(room, actor, state) { if (!room) return room const role = viewerRole(room, actor, state) @@ -1062,6 +1070,7 @@ export function install(rt) { const handle = String(member.handle || member.id || '') const displayName = cleanPeerName(member.displayName || member.name, handle) const ownerName = cleanPeerName(member.ownerName || member.ownerHome, '') + const label = cleanPeerName(member.label, '') return { kind: 'bot', remote: true, @@ -1070,6 +1079,7 @@ export function install(rt) { ownerHome: member.ownerHome, ownerName, displayName, + ...(label ? { label } : {}), // Face-to-face: the matching room is the scope. No person-grant is implied. roomOnly: member.roomOnly === true || member.published !== true, role: 'member', @@ -1080,6 +1090,7 @@ export function install(rt) { } function faceLocalRecord(bot, joinedSeq, at, member) { + const label = cleanPeerName(member?.label, '') return { kind: 'bot', id: bot.id, @@ -1087,11 +1098,41 @@ export function install(rt) { displayName: bot.name ?? bot.id, handle: bot.handle, ownerName: cleanPeerName(member?.ownerName, ''), + ...(label ? { label } : {}), joinedSeq, joinedAt: at, } } + function faceSameBot(p, { local, handle }) { + if (local) return p.kind === 'bot' && !p.remote && p.id === local.id + return p.remote && (p.handle === handle || p.id === handle) + } + + function faceLiveKey(p, byHandle) { + const key = p.remote + ? (p.handle || p.id) + : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) + return String(key || '').toLowerCase() + } + + function applyFaceMemberFields(existing, member) { + if (existing.remote) { + existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) + existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + existing.roomOnly = member.roomOnly === true || member.published !== true + const label = cleanPeerName(member.label, '') + if (label) existing.label = label + const look = faceLookFields(member) + if (look.color) existing.color = look.color + if (look.avatar) existing.avatar = look.avatar + } else { + if (member.ownerName) existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + const label = cleanPeerName(member.label, '') + if (label) existing.label = label + } + } + rt.syncFaceParticipants = function syncFaceParticipants(room, members, localBots = []) { materialize(room) const at = Date.now() @@ -1106,21 +1147,16 @@ export function install(rt) { if (!handle) continue liveKeys.add(handle.toLowerCase()) const local = byHandle.get(handle.toLowerCase()) - const existing = currentParticipants(room).find(p => { - if (local) return p.kind === 'bot' && !p.remote && p.id === local.id - return p.remote && (p.handle === handle || p.id === handle) - }) + const matches = (room.participants ?? []).filter(p => faceSameBot(p, { local, handle })) + const existing = matches.find(p => p.leftSeq === undefined) || matches.at(-1) if (existing) { - if (existing.remote) { - existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) - existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) - existing.roomOnly = member.roomOnly === true || member.published !== true - const look = faceLookFields(member) - if (look.color) existing.color = look.color - if (look.avatar) existing.avatar = look.avatar - } else if (member.ownerName) { - existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + if (existing.leftSeq !== undefined) { + delete existing.leftSeq + delete existing.leftAt + existing.joinedSeq = seq + existing.joinedAt = at } + applyFaceMemberFields(existing, member) continue } const record = local @@ -1128,14 +1164,16 @@ export function install(rt) { : faceRemoteRecord(member, seq, at) room.participants.push(record) } + const seenLive = new Set() for (const p of currentParticipants(room)) { - const key = (p.remote ? (p.handle || p.id) : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) || '') - const match = String(key).toLowerCase() + const match = faceLiveKey(p, byHandle) const still = [...liveKeys].some(h => h === match || h === String(p.id).toLowerCase() || (p.handle && h === String(p.handle).toLowerCase())) - if (!still) { + if (!still || (match && seenLive.has(match))) { p.leftSeq = seq p.leftAt = at + continue } + if (match) seenLive.add(match) } syncMembers(room) return room @@ -1163,13 +1201,13 @@ export function install(rt) { seq: 0, seen: {}, participants: [], - face: { roomId: serverId, status: spec.status || 'candidate', expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, + face: { roomId: serverId, status: faceStatusFrom(spec, 'candidate'), expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, } state.rooms[id] = room } room.face = { roomId: serverId, - status: spec.status || room.face.status || 'candidate', + status: faceStatusFrom(spec, room.face?.status), expiresAt: spec.expiresAt ?? room.face.expiresAt ?? null, startedAt: spec.startedAt ?? room.face.startedAt ?? null, eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index 543e48738..a17f6bc0e 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -115,6 +115,35 @@ test('lookBotForRemote seeds a distinct look from the handle when there is no ch assert.equal(lookBotForRemote(person, { bots: [chat] }), chat) }) +test('room avatars include live remote face-to-face members', () => { + const roster = { + byId: { a: { id: 'a', name: 'Chief', color: 'blue', avatar: { shape: 'whale' } } }, + bots: [{ id: 'r1', name: 'Coder', remote: { handle: '@coder.bob.home' }, color: 'green' }], + } + assert.equal(typeof roomAvatarPeople, 'function') + const empty = roomAvatarPeople({ members: [], participants: [] }, roster) + assert.ok(Array.isArray(empty)) + assert.equal(empty.length, 0) + const face = roomAvatarPeople({ + members: ['a'], + participants: [ + { kind: 'bot', id: 'a', handle: '@a.me.home' }, + { kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', leftSeq: undefined }, + { kind: 'bot', remote: true, handle: '@gone.bob.home', displayName: 'Gone', leftSeq: 3 }, + ], + }, roster) + assert.equal(face[0].id, 'a') + assert.equal(face[1].remote.handle, '@coder.bob.home') + assert.equal(face.length, 2) + const onlyRemote = roomAvatarPeople({ + members: [], + participants: [{ kind: 'bot', remote: true, handle: '@bot.friend.home', displayName: '助手' }], + }, { byId: {}, bots: [] }) + assert.equal(onlyRemote.length, 1) + assert.equal(onlyRemote[0].remote.handle, '@bot.friend.home') + assert.notEqual(onlyRemote[0].id, undefined) +}) + test('handles equal modulo @ and case share one look; a stored look still wins', () => { const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } }) const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } }) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 75f6b0894..8d0b030be 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -285,6 +285,34 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) }) +test('face-to-face rejoin revives a left participant instead of duplicating', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const spec = { + roomId: 'frm_rejoin', + localId: 'face-rejoin', + status: 'open', + localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], + } + const both = [ + { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, label: 'Coder · Bob' }, + ] + await rt.upsertFaceRoom({ ...spec, members: both }) + const room = rt.state.rooms['face-rejoin'] + await rt.upsertFaceRoom({ ...spec, members: [both[0]] }) + const left = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(left.length, 1) + assert.ok(left[0].leftSeq !== undefined) + await rt.upsertFaceRoom({ ...spec, members: both }) + const again = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(again.length, 1) + assert.equal(again[0].leftSeq, undefined) + assert.equal(again[0].label, 'Coder · Bob') + await rt.upsertFaceRoom({ ...spec, status: 'open', startedAt: '2026-10-16T10:00:00.000Z', members: both }) + assert.equal(room.face.status, 'open') +}) + test('fanout to remotes fences peer text and never names unpublished specialists', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 19fd43d8f..acd4944d0 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -47,6 +47,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t The default card (`flybot-slots.js`) shows where a held request came from: `senderName` / `targetName` resolved server-side, plus a delegated marker when the send was relayed (`hop > 0`). The strings live in `i18n.js` (`Sent by {sender}`, `Sent by {sender} · delegated`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). +### Room avatars in face-to-face chats + +- `RoomAvatar` (`src/client/mark.js` `roomAvatarPeople`) seats current local `room.members` and live remote `participants` (handle-seeded via `lookBotForRemote`). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header. + ### Remote Bot display - A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 5dabca0b2..ed4ee6c31 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -59,6 +59,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t ### Face-to-face participant look - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. +- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. ### FlyBot display copy diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index a380d70c9..91e6b2d19 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -65,6 +65,10 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `flybot.rooms.applyInbound` turns a Bot Social inbox envelope into the owner's inbound-share projection; `leaveInbound` hides those rooms when the invited Bot is removed or its grant is revoked. Face-to-face methods are unchanged. Still not team mode. +### Face-to-face owner lines + +- `onFaceLine` receives every appended room line, including the owner's typed human lines. The social plugin sends those through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the matching room sees the text; incoming envelopes apply as `authorKind: 'human'`. Bot replies stay bot-authored. Still not team mode. + ### Host Bot look for classic-script plugins - Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index a861d8302..27d3efd37 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). Friends' Bots shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, and **Friend requests** are those rows; Friends' Bots has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot, no owner subtitle); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** is one compact checkbox (tooltip) on the Face-to-face rooms header, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, Friends' Bots rows, and core group overview uses the host `BotAvatar` (chat record, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, and **Friend requests** are those rows; Friends' Bots has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** is one compact checkbox (tooltip) on the Face-to-face rooms header, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, Friends' Bots rows, and core group overview uses the host `BotAvatar` (chat record, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -48,7 +48,11 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Inline approvals and compact Friends' Bots (#114, #115) - Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as an inline card in the open chat (`flybot.session.dock`): draft text, **Approve** / **Reject** for the owner, **Approve all** when several, and **Waiting for the owner's approval** with no buttons for anyone else. The owner's click is consent (`confirm_code` in the same `decide_approval` call). After a decision the card reads Approved or Rejected. The Friends' Bots "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is a single compact checkbox (tooltip only) on the Face-to-face rooms header and the open room's overview — not on each pending row or room card. -- Each Face-to-face room in Friends' Bots is one row: name, a small member-avatar stack (`+N`), a muted open/closed/left chip, **Open** and **Leave** (and **Start** while matching). Closed and left rooms sit under **Past rooms**. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The Friends' Bots header is title, Bot count, and the **N to approve** chip — no **+ Add**. **Add friend**, **Friend requests**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Friend requests expand Friends' Bots and show the same join / Accept / Decline panel. +- Each Face-to-face room in Friends' Bots is one row: name, a small member-avatar stack (`+N`), a muted open/closed/left/`10m` chip, **Open** and **Leave** (and **Start** while matching). Closed and left rooms sit under **Past rooms**; with no live room the Face-to-face header and Auto-send are omitted. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The Friends' Bots header is title, Bot count, and the **N to approve** chip — no **+ Add**. **Add friend**, **Friend requests**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Friend requests expand Friends' Bots and show the same join / Accept / Decline panel. + +### Face-to-face two-instance rooms (#117) + +- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer. ### Inbound shared groups diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index c8795efd5..0a706ea8d 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -46,8 +46,10 @@ diff --git a/src/client/characters.js b/src/client/characters.js diff --git a/src/client/mark.js b/src/client/mark.js --- a/src/client/mark.js +++ b/src/client/mark.js -@@ -2,7 +2,7 @@ import { createElement as h, useEffect, useRef, useState } from 'react' - import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' +@@ -1,8 +1,8 @@ + import { createElement as h, useEffect, useRef, useState } from 'react' +-import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' ++import { fnv1a, lookOf, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' import { t } from './i18n.js' -export { colorOf, lookOf } from './characters.js' @@ -55,6 +57,52 @@ diff --git a/src/client/mark.js b/src/client/mark.js // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. +@@ -130,10 +130,42 @@ function clusterSeats(count, frame) { + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]] + } + ++export function roomAvatarPeople(room, roster) { ++ const members = (room?.members ?? []).map(id => roster?.byId?.[id]).filter(Boolean) ++ const seen = new Set(members.map(bot => bot.id)) ++ const remotes = [] ++ for (const p of room?.participants ?? []) { ++ if (p.leftSeq !== undefined) continue ++ if (p.kind === 'human') continue ++ if (!p.remote && p.id && seen.has(p.id)) continue ++ if (!p.remote && p.id && roster?.byId?.[p.id]) { ++ if (!seen.has(p.id)) { ++ members.push(roster.byId[p.id]) ++ seen.add(p.id) ++ } ++ continue ++ } ++ const handle = p.handle || (p.remote ? p.id : '') ++ if (!handle) continue ++ if (members.some(bot => bot.remote?.handle === handle || bot.handle === handle)) continue ++ const key = `h:${handle}` ++ if (seen.has(key)) continue ++ seen.add(key) ++ remotes.push(lookBotForRemote({ ++ handle, ++ displayName: p.displayName, ++ name: p.displayName, ++ label: p.label, ++ color: p.color, ++ avatar: p.avatar, ++ }, roster)) ++ } ++ if (members.length === 1 && remotes.length === 0) return [members[0], null] ++ return [...members, ...remotes] ++} ++ + export function RoomAvatar({ room, roster, size = 36 }) { +- const members = room.members.map(id => roster.byId[id]).filter(Boolean) +- // A group with one Bot left shows the user beside it. +- const people = members.length === 1 ? [members[0], null] : members ++ const people = roomAvatarPeople(room, roster) + const seat = (bot, seatSize, key) => (bot + ? h(BotMark, { key, bot, size: seatSize }) + : h('span', { key, className: 'bt-cluster-you', style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } })) diff --git a/src/host/channel.js b/src/host/channel.js --- a/src/host/channel.js +++ b/src/host/channel.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a380db6ee..1bbb99c37 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -557,7 +557,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,318 @@ function maskOf(shape) { +@@ -1242,6 +1242,350 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -687,9 +687,41 @@ diff --git a/client.js b/client.js + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +} ++function roomAvatarPeople(room2, roster) { ++ const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); ++ const seen = new Set(members.map((bot) => bot.id)); ++ const remotes = []; ++ for (const p of room2?.participants ?? []) { ++ if (p.leftSeq !== void 0) continue; ++ if (p.kind === "human") continue; ++ if (!p.remote && p.id && seen.has(p.id)) continue; ++ if (!p.remote && p.id && roster?.byId?.[p.id]) { ++ if (!seen.has(p.id)) { ++ members.push(roster.byId[p.id]); ++ seen.add(p.id); ++ } ++ continue; ++ } ++ const handle = p.handle || (p.remote ? p.id : ""); ++ if (!handle) continue; ++ if (members.some((bot) => bot.remote?.handle === handle || bot.handle === handle)) continue; ++ const key = `h:${handle}`; ++ if (seen.has(key)) continue; ++ seen.add(key); ++ remotes.push(lookBotForRemote({ ++ handle, ++ displayName: p.displayName, ++ name: p.displayName, ++ label: p.label, ++ color: p.color, ++ avatar: p.avatar ++ }, roster)); ++ } ++ if (members.length === 1 && remotes.length === 0) return [members[0], null]; ++ return [...members, ...remotes]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { -+ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -+ const people = members.length === 1 ? [members[0], null] : members; ++ const people = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); + if (people.length === 0) return (0, import_react.createElement)(BotMark, { size }); + if (size <= 20 && people.length > 1) { @@ -1129,10 +1161,11 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2822,259 +2822,141 @@ function installCompiledStyles(text, tag) { +@@ -2821,260 +2821,142 @@ function compileFragmentSelectors(text, names) { + function installCompiledStyles(text, tag) { const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); - const render = () => { +- const render = () => { - const names = /* @__PURE__ */ new Set(); - for (const sheet of document.styleSheets) { - if (sheet.ownerNode?.dataset?.dshBot) continue; @@ -1291,6 +1324,7 @@ diff --git a/client.js b/client.js - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ const render = () => { + const names = /* @__PURE__ */ new Set(); + for (const sheet of document.styleSheets) { + if (sheet.ownerNode?.dataset?.dshBot) continue; diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 09140204c..e1ad656f2 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -185,6 +185,14 @@ export function isFaceRoom(room) { return room?.origin === 'face-to-face' } +function faceStatusFrom(spec, current) { + const status = spec?.status + if (status === 'open' || status === 'closed' || status === 'left') return status + if (spec?.startedAt) return 'open' + if (status) return status + return current || 'candidate' +} + export function presentRoom(room, actor, state) { if (!room) return room const role = viewerRole(room, actor, state) @@ -1062,6 +1070,7 @@ export function install(rt) { const handle = String(member.handle || member.id || '') const displayName = cleanPeerName(member.displayName || member.name, handle) const ownerName = cleanPeerName(member.ownerName || member.ownerHome, '') + const label = cleanPeerName(member.label, '') return { kind: 'bot', remote: true, @@ -1070,6 +1079,7 @@ export function install(rt) { ownerHome: member.ownerHome, ownerName, displayName, + ...(label ? { label } : {}), // Face-to-face: the matching room is the scope. No person-grant is implied. roomOnly: member.roomOnly === true || member.published !== true, role: 'member', @@ -1080,6 +1090,7 @@ export function install(rt) { } function faceLocalRecord(bot, joinedSeq, at, member) { + const label = cleanPeerName(member?.label, '') return { kind: 'bot', id: bot.id, @@ -1087,11 +1098,41 @@ export function install(rt) { displayName: bot.name ?? bot.id, handle: bot.handle, ownerName: cleanPeerName(member?.ownerName, ''), + ...(label ? { label } : {}), joinedSeq, joinedAt: at, } } + function faceSameBot(p, { local, handle }) { + if (local) return p.kind === 'bot' && !p.remote && p.id === local.id + return p.remote && (p.handle === handle || p.id === handle) + } + + function faceLiveKey(p, byHandle) { + const key = p.remote + ? (p.handle || p.id) + : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) + return String(key || '').toLowerCase() + } + + function applyFaceMemberFields(existing, member) { + if (existing.remote) { + existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) + existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + existing.roomOnly = member.roomOnly === true || member.published !== true + const label = cleanPeerName(member.label, '') + if (label) existing.label = label + const look = faceLookFields(member) + if (look.color) existing.color = look.color + if (look.avatar) existing.avatar = look.avatar + } else { + if (member.ownerName) existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + const label = cleanPeerName(member.label, '') + if (label) existing.label = label + } + } + rt.syncFaceParticipants = function syncFaceParticipants(room, members, localBots = []) { materialize(room) const at = Date.now() @@ -1106,21 +1147,16 @@ export function install(rt) { if (!handle) continue liveKeys.add(handle.toLowerCase()) const local = byHandle.get(handle.toLowerCase()) - const existing = currentParticipants(room).find(p => { - if (local) return p.kind === 'bot' && !p.remote && p.id === local.id - return p.remote && (p.handle === handle || p.id === handle) - }) + const matches = (room.participants ?? []).filter(p => faceSameBot(p, { local, handle })) + const existing = matches.find(p => p.leftSeq === undefined) || matches.at(-1) if (existing) { - if (existing.remote) { - existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) - existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) - existing.roomOnly = member.roomOnly === true || member.published !== true - const look = faceLookFields(member) - if (look.color) existing.color = look.color - if (look.avatar) existing.avatar = look.avatar - } else if (member.ownerName) { - existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + if (existing.leftSeq !== undefined) { + delete existing.leftSeq + delete existing.leftAt + existing.joinedSeq = seq + existing.joinedAt = at } + applyFaceMemberFields(existing, member) continue } const record = local @@ -1128,14 +1164,16 @@ export function install(rt) { : faceRemoteRecord(member, seq, at) room.participants.push(record) } + const seenLive = new Set() for (const p of currentParticipants(room)) { - const key = (p.remote ? (p.handle || p.id) : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) || '') - const match = String(key).toLowerCase() + const match = faceLiveKey(p, byHandle) const still = [...liveKeys].some(h => h === match || h === String(p.id).toLowerCase() || (p.handle && h === String(p.handle).toLowerCase())) - if (!still) { + if (!still || (match && seenLive.has(match))) { p.leftSeq = seq p.leftAt = at + continue } + if (match) seenLive.add(match) } syncMembers(room) return room @@ -1163,13 +1201,13 @@ export function install(rt) { seq: 0, seen: {}, participants: [], - face: { roomId: serverId, status: spec.status || 'candidate', expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, + face: { roomId: serverId, status: faceStatusFrom(spec, 'candidate'), expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, } state.rooms[id] = room } room.face = { roomId: serverId, - status: spec.status || room.face.status || 'candidate', + status: faceStatusFrom(spec, room.face?.status), expiresAt: spec.expiresAt ?? room.face.expiresAt ?? null, startedAt: spec.startedAt ?? room.face.startedAt ?? null, eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index 543e48738..a17f6bc0e 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -115,6 +115,35 @@ test('lookBotForRemote seeds a distinct look from the handle when there is no ch assert.equal(lookBotForRemote(person, { bots: [chat] }), chat) }) +test('room avatars include live remote face-to-face members', () => { + const roster = { + byId: { a: { id: 'a', name: 'Chief', color: 'blue', avatar: { shape: 'whale' } } }, + bots: [{ id: 'r1', name: 'Coder', remote: { handle: '@coder.bob.home' }, color: 'green' }], + } + assert.equal(typeof roomAvatarPeople, 'function') + const empty = roomAvatarPeople({ members: [], participants: [] }, roster) + assert.ok(Array.isArray(empty)) + assert.equal(empty.length, 0) + const face = roomAvatarPeople({ + members: ['a'], + participants: [ + { kind: 'bot', id: 'a', handle: '@a.me.home' }, + { kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', leftSeq: undefined }, + { kind: 'bot', remote: true, handle: '@gone.bob.home', displayName: 'Gone', leftSeq: 3 }, + ], + }, roster) + assert.equal(face[0].id, 'a') + assert.equal(face[1].remote.handle, '@coder.bob.home') + assert.equal(face.length, 2) + const onlyRemote = roomAvatarPeople({ + members: [], + participants: [{ kind: 'bot', remote: true, handle: '@bot.friend.home', displayName: '助手' }], + }, { byId: {}, bots: [] }) + assert.equal(onlyRemote.length, 1) + assert.equal(onlyRemote[0].remote.handle, '@bot.friend.home') + assert.notEqual(onlyRemote[0].id, undefined) +}) + test('handles equal modulo @ and case share one look; a stored look still wins', () => { const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } }) const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } }) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 75f6b0894..8d0b030be 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -285,6 +285,34 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) }) +test('face-to-face rejoin revives a left participant instead of duplicating', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const spec = { + roomId: 'frm_rejoin', + localId: 'face-rejoin', + status: 'open', + localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], + } + const both = [ + { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, label: 'Coder · Bob' }, + ] + await rt.upsertFaceRoom({ ...spec, members: both }) + const room = rt.state.rooms['face-rejoin'] + await rt.upsertFaceRoom({ ...spec, members: [both[0]] }) + const left = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(left.length, 1) + assert.ok(left[0].leftSeq !== undefined) + await rt.upsertFaceRoom({ ...spec, members: both }) + const again = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(again.length, 1) + assert.equal(again[0].leftSeq, undefined) + assert.equal(again[0].label, 'Coder · Bob') + await rt.upsertFaceRoom({ ...spec, status: 'open', startedAt: '2026-10-16T10:00:00.000Z', members: both }) + assert.equal(room.face.status, 'open') +}) + test('fanout to remotes fences peer text and never names unpublished specialists', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/plugins/social/client.js b/plugins/social/client.js index 2035a1c9a..501a8986d 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -96,8 +96,10 @@ window.__ModuleLoader__.load({ .fs-digits{flex:1 1 auto;min-width:0;width:0;height:30px;padding:0 9px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:16px;letter-spacing:.24em;font-variant-numeric:tabular-nums;outline:none;text-align:center} .fs-digits:focus{border-color:var(--bt-accent,#4d6bfe)} .fs-face{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px;min-width:0} +.fs-face-item{display:flex;flex-direction:column;gap:2px;min-width:0;width:100%} .fs-face-row{display:flex;align-items:center;gap:6px;min-width:0;width:100%;padding:2px 0;flex-wrap:nowrap} -.fs-face-row .fs-name{flex:1 1 auto;min-width:0} +.fs-face-row .fs-name{flex:1 1 0;min-width:0} +.fs-face-actions .fs-btn-sm{height:22px;padding:0 6px;font-size:11px} .fs-stack{display:flex;flex-direction:row;align-items:center;flex:none} .fs-stack .fs-face-mark,.fs-stack [data-flybot=face-card-member]{margin-left:-6px} .fs-stack .fs-face-mark:first-child,.fs-stack [data-flybot=face-card-member]:first-child{margin-left:0} @@ -249,6 +251,15 @@ window.__ModuleLoader__.load({ }, inner) } + function faceTileName(member) { + const label = String(member?.label ?? '').trim() + if (label) return label + const name = String(member?.displayName || member?.name || member?.handle || 'Bot').trim() + const owner = String(member?.ownerName ?? '').trim() + if (owner && name) return `${name} · ${owner}` + return name + } + function FaceTile({ bot, name, handle, mine, pick, selected, onToggle } = {}) { const mark = h('span', { className: 'fs-tile-mark' }, h(FaceAvatar, { bot, size: 48 }), @@ -498,7 +509,7 @@ window.__ModuleLoader__.load({ members.map(member => h(FaceTile, { key: member.handle || member.id, bot: faceLook({ roster, remoteChats, bots, member }), - name: member.displayName || member.label || member.handle, + name: faceTileName(member), handle: member.handle, mine: memberIsMine(member, bots, room), }))), @@ -543,7 +554,16 @@ window.__ModuleLoader__.load({ function faceChip(room) { if (room.status === 'closed') return 'closed' if (room.status === 'left') return 'left' - if (room.status === 'candidate') return room.expiresAt ? countdown(room.expiresAt) : 'joining' + if (room.status === 'candidate') { + if (!room.expiresAt) return 'joining' + const at = Date.parse(room.expiresAt) + if (!Number.isFinite(at)) return 'joining' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'ended' + const minutes = Math.ceil(left / 60_000) + if (minutes >= 1) return `${minutes}m` + return `${Math.max(1, Math.ceil(left / 1000))}s` + } return 'open' } function FaceRoomCard({ room, actions, onChange, roster, remoteChats } = {}) { @@ -565,17 +585,18 @@ window.__ModuleLoader__.load({ const look = (m) => (m.remote === false || m.id && roster?.byId?.[m.id] ? faceLook({ roster, local: roster?.byId?.[m.id] || { id: m.id, name: m.displayName } }) : faceLook({ roster, remoteChats, member: m })) - return h('div', { className: 'fs-face-row', 'data-flybot': 'face-room', 'data-status': room.status }, - h('span', { className: 'fs-stack', 'aria-label': 'Members' }, - shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle }, - h(FaceAvatar, { bot: look(m), size: 20 }))), - extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null), - h('span', { className: 'fs-name' }, room.name || 'Face-to-face room'), - h('span', { className: 'fs-chip', 'data-flybot': 'face-status' }, faceChip(room)), - h('span', { className: 'fs-face-actions' }, - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy || !room.roomId, onClick: () => { if (room.roomId) actions?.openSession?.(room.roomId) } }, 'Open'), - candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Start') : null, - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Leave')), + return h('div', { className: 'fs-face-item', 'data-flybot': 'face-room', 'data-status': room.status }, + h('div', { className: 'fs-face-row' }, + h('span', { className: 'fs-stack', 'aria-label': 'Members' }, + shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle }, + h(FaceAvatar, { bot: look(m), size: 20 }))), + extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null), + h('span', { className: 'fs-name' }, room.name || 'Face-to-face room'), + h('span', { className: 'fs-chip', 'data-flybot': 'face-status' }, faceChip(room)), + h('span', { className: 'fs-face-actions' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy || !room.roomId, onClick: () => { if (room.roomId) actions?.openSession?.(room.roomId) } }, 'Open'), + candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Start') : null, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Leave'))), note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } @@ -733,23 +754,22 @@ window.__ModuleLoader__.load({ const [pastOpen, setPastOpen] = useState(false) const live = rooms.filter(room => !isPastFace(room)) const past = rooms.filter(isPastFace) - const candidate = live.some(room => room.status === 'candidate') - const load = (poll) => call('social/face-rooms', { poll }).then(value => { + const load = (poll = true) => call('social/face-rooms', { poll }).then(value => { setRooms(Array.isArray(value?.rooms) ? value.rooms : []) if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) }, () => {}) useEffect(() => { if (!enabled) return undefined - load(false) + load(true) if (typeof setInterval !== 'function') return undefined const tick = () => { if (typeof document !== 'undefined' && document.hidden) return - load(candidate) + load(true) } - const timer = setInterval(tick, candidate ? 5000 : 30_000) + const timer = setInterval(tick, 5000) timer?.unref?.() return () => clearInterval(timer) - }, [enabled, candidate]) + }, [enabled]) const toggle = async (next) => { setAutoBusy(true) setAutoError('') @@ -761,19 +781,20 @@ window.__ModuleLoader__.load({ } if (!rooms.length) return null const card = (room) => h(FaceRoomCard, { - key: room.faceRoomId || room.roomId, room, actions, onChange: load, roster, remoteChats, + key: room.faceRoomId || room.roomId, room, actions, onChange: () => load(true), roster, remoteChats, }) + const pastFold = past.length ? h('div', { className: 'fs-past', 'data-flybot': 'face-past' }, + h('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: () => setPastOpen(open => !open) }, + h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), + 'Past rooms', + h('span', { className: 'fs-count' }, String(past.length))), + pastOpen ? past.map(card) : null) : null return h('div', { 'data-flybot': 'face-rooms' }, - h('div', { className: 'fs-label fs-face-head' }, + live.length ? h('div', { className: 'fs-label fs-face-head' }, 'Face-to-face rooms', - h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: toggle, error: autoError })), + h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: toggle, error: autoError })) : null, live.map(card), - past.length ? h('div', { className: 'fs-past', 'data-flybot': 'face-past' }, - h('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: () => setPastOpen(open => !open) }, - h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), - 'Past rooms', - h('span', { className: 'fs-count' }, String(past.length))), - pastOpen ? past.map(card) : null) : null) + pastFold) } function FaceRoomOverview({ room } = {}) { @@ -875,6 +896,7 @@ window.__ModuleLoader__.load({ if (!openRef.current) return if (typeof document !== 'undefined' && document.hidden) return loadApprovals() + load() } const timer = setInterval(tick, 15_000) timer?.unref?.() diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index d9cb3e021..e23acc8fd 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -182,6 +182,59 @@ export function faceMemberLabel(member) { return `${name} · ${owner}'s Bot` } +export const FACE_HUMAN_MARK = 'FLYBOT-FACE-HUMAN' + +export function encodeFaceHuman(payload = {}) { + const who = cleanFaceName(payload.who, 'Someone') + const text = String(payload.text ?? '').slice(0, 18_000) + return `${FACE_HUMAN_MARK}\n${JSON.stringify({ v: 1, who, text })}` +} + +export function parseFaceHuman(text) { + const raw = String(text ?? '') + const at = raw.indexOf(FACE_HUMAN_MARK) + if (at < 0) return undefined + const start = raw.indexOf('{', at) + if (start < 0) return undefined + let depth = 0 + let end = -1 + for (let i = start; i < raw.length; i += 1) { + const ch = raw[i] + if (ch === '{') depth += 1 + else if (ch === '}') { + depth -= 1 + if (depth === 0) { end = i; break } + } + } + if (end < 0) return undefined + try { + const data = JSON.parse(raw.slice(start, end + 1)) + if (data?.v !== 1 || typeof data.text !== 'string') return undefined + return { who: cleanFaceName(data.who, 'Someone'), text: String(data.text) } + } catch { + return undefined + } +} + +export function faceOutbound(line, { locals = [], asBots = [] } = {}) { + if (!line || line.kind) return undefined + if (line.remote === true) return undefined + const author = line.authorId || line.botId + const local = locals.find(bot => bot.id === author || bot.handle === author) + if (local?.botId) return { text: String(line.text ?? ''), botId: local.botId, human: false } + if (line.authorKind === 'human') { + const speaker = asBots.map(handle => locals.find(bot => bot.handle === handle)).find(Boolean) + || locals.find(bot => bot.botId) + if (!speaker?.botId) return undefined + return { + text: encodeFaceHuman({ who: line.who, text: line.text }), + botId: speaker.botId, + human: true, + } + } + return undefined +} + export function platformJoinText(member) { const name = cleanFaceName(member?.display_name || member?.displayName, 'Bot') const owner = cleanFaceName(member?.owner_name || member?.ownerName, 'Someone') @@ -305,17 +358,20 @@ export function renderFaceEvent(event) { if (!viewed) return undefined if (viewed.type === 'room.message') { const from = viewed.from || {} - const label = from.label || faceMemberLabel(from) const body = viewed.untrustedText ?? '' + const human = parseFaceHuman(body) + const label = human?.who || from.label || faceMemberLabel(from) + const text = human ? human.text : body return { kind: undefined, system: false, who: label, - text: body, - fenced: fenceFaceText(label, body), - authorId: from.handle, - authorKind: 'bot', + text, + fenced: fenceFaceText(label, text), + authorId: human ? (from.handle || 'human') : from.handle, + authorKind: human ? 'human' : 'bot', remote: true, + human: Boolean(human), ownerName: from.ownerName, trust: 'peer_untrusted', eventId: viewed.eventId, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 74da9991c..cc3e786f8 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -42,6 +42,8 @@ import { roomOnlyForJoin, faceMemberLabel, viewMe, + faceOutbound, + platformJoinText, } from './face-rooms.js' import { hideRemoteInSettings, @@ -1365,7 +1367,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ownerName: p.ownerName, remote: p.remote === true, roomOnly: p.roomOnly === true || p.published !== true, - label: faceMemberLabel({ displayName: p.displayName, ownerName: p.ownerName, handle: p.handle }) || p.displayName || p.id, + label: p.label || faceMemberLabel({ displayName: p.displayName, ownerName: p.ownerName, handle: p.handle }) || p.displayName || p.id, ...(p.color ? { color: p.color } : {}), ...(p.avatar ? { avatar: p.avatar } : {}), })), @@ -1375,6 +1377,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp async function applyFaceSnapshot(snapshot, { asBot } = {}) { if (!snapshot?.roomId || !flybot.rooms?.upsertFace) return undefined const locals = bringable() + const hosted = flybot.rooms.ofFace?.(snapshot.roomId) + const previousLive = new Set((hosted?.participants ?? []) + .filter(p => p.leftSeq === undefined) + .map(p => norm(p.handle || p.id)) + .filter(Boolean)) const room = await flybot.rooms.upsertFace({ roomId: snapshot.roomId, status: snapshot.status, @@ -1385,6 +1392,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp handle: m.handle, displayName: m.displayName, ownerName: m.ownerName, + label: m.label, roomOnly: m.roomOnly === true || m.published !== true, ...(m.color ? { color: m.color } : {}), ...(m.avatar ? { avatar: m.avatar } : {}), @@ -1401,14 +1409,27 @@ export function createSocial(service, config = {}, { client: given, api: givenAp seen: prev?.seen ?? [], }, asBot) let seen = settings.faceRooms[snapshot.roomId].seen + const joinedFromEvents = new Set() for (const event of snapshot.events ?? []) { if (event.eventId && seen.includes(event.eventId)) continue const line = renderFaceEvent(event) if (!line) continue seen = rememberEvent(seen, event.eventId) if (line.system) { + if (line.kind === 'joined' && event.member?.handle) { + const handle = norm(event.member.handle) + const key = `join:${handle}` + joinedFromEvents.add(handle) + if (seen.includes(key)) continue + seen = rememberEvent(seen, key) + } flybot.rooms.applyFaceLine?.(room.id, line) void flybot.rooms.postNotice?.(room.id, line.text) + } else if (line.human || line.authorKind === 'human') { + const local = locals.some(bot => bot.handle && bot.handle === line.authorId) + if (local) continue + flybot.rooms.applyFaceLine?.(room.id, { ...line, remote: true, authorKind: 'human' }) + void flybot.rooms.postNotice?.(room.id, `${line.who}: ${line.text}`) } else { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue @@ -1416,6 +1437,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } if (event.eventCursor) settings.faceRooms[snapshot.roomId].cursor = Math.max(settings.faceRooms[snapshot.roomId].cursor, event.eventCursor) } + for (const member of snapshot.members ?? []) { + const handle = norm(member.handle) + const key = `join:${handle}` + if (!handle || previousLive.has(handle) || joinedFromEvents.has(handle) || seen.includes(key)) continue + seen = rememberEvent(seen, key) + const text = platformJoinText(member) + flybot.rooms.applyFaceLine?.(room.id, { kind: 'joined', who: 'Room', text, authorKind: 'system' }) + void flybot.rooms.postNotice?.(room.id, text) + } settings.faceRooms[snapshot.roomId].seen = seen saveSettings() return flybot.rooms.ofFace?.(snapshot.roomId) ?? room @@ -1523,20 +1553,31 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] if (!id) throw new Error('which room?') const snapshot = await faceNeed().start(id) - const room = await applyFaceSnapshot(snapshot) + const room = await applyFaceSnapshot({ + ...snapshot, + status: snapshot.status === 'closed' ? 'closed' : 'open', + startedAt: snapshot.startedAt || new Date().toISOString(), + }) return viewHostedFace(room, { status: 'open' }) } if (flybot.rooms?.onFaceLine) { disposers.push(flybot.rooms.onFaceLine(async (room, line) => { - if (line?.kind) return const serverId = room?.face?.roomId if (!serverId || !faceApi) return - const author = line?.authorId || line?.botId - const local = bringable().find(bot => bot.id === author || bot.handle === author) - if (!local) return + const outbound = faceOutbound(line, { + locals: bringable(), + asBots: asBotsOf(settings.faceRooms[serverId]), + }) + if (!outbound) return try { - const sent = await faceApi.send(serverId, { text: String(line.text ?? ''), botId: local.botId }) - if (sent?.status === 'pending_approval') pendingCache.at = 0 + const sent = await faceApi.send(serverId, { text: outbound.text, botId: outbound.botId }) + if (sent?.status === 'pending_approval') { + pendingCache.at = 0 + if (outbound.human && sent.approvalId) { + await confirmedCall('decide_approval', { approval_id: sent.approvalId, decision: 'approved' }) + .catch(error => warn('face-room owner line not auto-approved', error)) + } + } } catch (error) { warn('face-room send failed', error) throw error diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 9c0686d30..9d74d6c12 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -24,6 +24,10 @@ import { createFaceRoomApi, FaceRoomError, mapRegisterError, + encodeFaceHuman, + parseFaceHuman, + faceOutbound, + FACE_HUMAN_MARK, } from '../src/face-rooms.js' test('join code is exactly six digits', () => { @@ -94,6 +98,29 @@ test('peer names and system copy are sanitized', () => { assert.equal(faceMemberLabel({ display_name: 'Chief', owner_name: 'Alice' }), "Alice's Chief") }) +test('owner-typed face-room lines encode as a human envelope', () => { + const packed = encodeFaceHuman({ who: 'BoxA', text: 'hello from the owner' }) + assert.match(packed, new RegExp(FACE_HUMAN_MARK)) + assert.deepEqual(parseFaceHuman(packed), { who: 'BoxA', text: 'hello from the owner' }) + const locals = [{ id: 'a', handle: '@chief.boxa.home', botId: 'bot_a' }] + assert.equal(faceOutbound({ kind: 'joined', text: 'x' }, { locals }), undefined) + assert.equal(faceOutbound({ authorKind: 'human', who: 'BoxA', text: 'hi', remote: true }, { locals }), undefined) + const bot = faceOutbound({ authorId: 'a', text: 'bot says' }, { locals }) + assert.deepEqual(bot, { text: 'bot says', botId: 'bot_a', human: false }) + const human = faceOutbound({ authorKind: 'human', who: 'BoxA', text: 'hi', authorId: 'owner-1' }, { locals, asBots: ['@chief.boxa.home'] }) + assert.equal(human.human, true) + assert.equal(human.botId, 'bot_a') + assert.deepEqual(parseFaceHuman(human.text), { who: 'BoxA', text: 'hi' }) + const rendered = renderFaceEvent({ + event_id: 'fev_h', event_cursor: 9, type: 'room.message', + from: { handle: '@chief.boxa.home', display_name: 'Chief', owner_name: 'BoxA' }, + untrusted_content: { text: packed }, + }) + assert.equal(rendered.authorKind, 'human') + assert.equal(rendered.who, 'BoxA') + assert.equal(rendered.text, 'hello from the owner') +}) + test('peer room.message text is fenced and cannot rebuild a closing marker', () => { const event = viewFaceEvent({ event_id: 'fev_1', diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 518a76b59..804df2d5f 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -11,6 +11,7 @@ import { apply as applySocial, inject, name } from '../index.js' import { approvalView, createSocial, draftText, homeHandleOf, onboard, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions } from '../src/mcp-client.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' +import { encodeFaceHuman } from '../src/face-rooms.js' import { hideRemoteInSettings, forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, HIDE_GONE_FETCHES, isHomeLevelHandle, isHiddenRemote, clearHiddenRemote, homeOfHiddenHandle, @@ -1192,11 +1193,76 @@ test('face-to-face join hosts a native room, maps errors, starts and leaves', as assert.equal(started.status, 'open') const listed = (await call('flybot/social/face-rooms', {})).value assert.equal(listed.rooms.length, 1) + assert.equal(listed.rooms[0].status, 'open') const left = (await call('flybot/social/face-leave', { faceRoomId: 'frm_123456' })).value assert.equal(left.status, 'closed') assert.equal(ctx.flybot.rooms.ofFace('frm_123456'), undefined) }) +test('face-to-face: a member already in the snapshot gets a join line when the event is missing', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { code: '123456' }) + server.face.rooms.get('frm_123456').members.push({ + bot_id: 'bot_peer', handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob', published: false, label: 'Coder · Bob', + }) + await call('flybot/social/face-rooms', { poll: true }) + const hosted = ctx.flybot.rooms.ofFace('frm_123456') + assert.ok((hosted.log ?? []).some(line => /Bob's Coder joined/.test(line.text))) + const live = (hosted.participants ?? []).filter(p => p.leftSeq === undefined && p.handle === '@coder.bob.home') + assert.equal(live.length, 1) +}) + +test('face-to-face: an owner-typed line is fanned out and shown as a human on the other side', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + let fanout + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origOn = scoped.rooms.onFaceLine.bind(scoped.rooms) + scoped.rooms.onFaceLine = (fn) => { + fanout = fn + return origOn(fn) + } + return scoped + } + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { code: '123456' }) + const room = ctx.flybot.rooms.ofFace('frm_123456') + assert.equal(typeof fanout, 'function') + await fanout(room, { authorKind: 'human', who: 'Danyang', text: 'hello from the owner', authorId: 'owner-1' }) + const sent = server.calls.find(c => String(c.name).includes('/messages')) + assert.ok(sent, 'owner line POSTs to the face-room messages route') + assert.match(String(sent.args?.text ?? ''), /FLYBOT-FACE-HUMAN/) + assert.match(String(sent.args?.text ?? ''), /hello from the owner/) + const body = server.face.rooms.get('frm_123456') + body.event_cursor += 1 + body.events.push({ + event_id: `fev_${body.event_cursor}`, event_cursor: body.event_cursor, type: 'room.message', + from: { handle: '@chief.boxa.home', display_name: 'Chief', owner_name: 'BoxA', published: false, label: 'Chief · BoxA' }, + trust: 'peer_untrusted', untrusted_content: { text: encodeFaceHuman({ who: 'BoxA', text: 'hello from the owner' }) }, + }) + await call('flybot/social/face-rooms', { poll: true }) + const hosted = ctx.flybot.rooms.ofFace('frm_123456') + assert.ok((hosted.log ?? []).some(line => line.authorKind === 'human' && line.text === 'hello from the owner' && /BoxA/.test(line.who))) +}) + test('face-to-face send pending_approval uses decide_approval', async (t) => { const { ctx, call, stop } = await team() t.after(stop) @@ -1588,7 +1654,8 @@ test('Face-to-face room card is one compact row with a status chip and no invali }, actions: { openSession() {} }, })) - assert.ok(tree.some(n => n.props['data-flybot'] === 'face-room' && n.props.className === 'fs-face-row')) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-room' && n.props.className === 'fs-face-item')) + assert.ok(tree.some(n => n.props.className === 'fs-face-row')) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('open'))) assert.ok(tree.some(n => n.props.className === 'fs-stack-more' && n.children.includes('+1'))) assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) @@ -1617,6 +1684,44 @@ test('Face-to-face past rooms sit under a Past rooms fold; Auto-send is once in const opened = walk(pluginOpen.FaceRooms({})) assert.ok(opened.some(n => n.props['data-status'] === 'closed')) assert.ok(opened.some(n => n.props['data-status'] === 'left')) + + const pastOnly = walk(pluginOf([ + [{ faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }], + false, false, '', false, + ]).FaceRooms({})) + assert.equal(pastOnly.some(n => n.props.className === 'fs-face-head' || (Array.isArray(n.children) && n.children.includes('Face-to-face rooms'))), false) + assert.equal(pastOnly.some(n => n.props['data-flybot'] === 'face-auto-send'), false) + assert.ok(pastOnly.some(n => n.props['data-flybot'] === 'face-past')) +}) + +test('Face-to-face room card uses a short status chip so Start is not clipped', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([]) + const tree = walk(plugin.FaceRoomCard({ + room: { + name: 'Face-to-face room', status: 'candidate', roomId: 'g1', + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + members: [{ handle: '@a', displayName: 'Chief' }], + }, + actions: { openSession() {} }, + })) + const chip = tree.find(n => n.props['data-flybot'] === 'face-status') + assert.ok(chip) + assert.match(String(chip.children.join('')), /^\d+m$/) + assert.equal(tree.some(n => typeof n.children?.[0] === 'string' && /minutes left/.test(n.children[0])), false) + assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Start'))) + assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) +}) + +test('Face-to-face rooms list polls Bot Social while Friends\' Bots is open', () => { + const calls = [] + const { walk, pluginOf } = loadSocialClient(async (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'social/face-rooms') return { rooms: [], autoApproveFaceRooms: false } + return {} + }) + walk(pluginOf([], true).FaceRooms({ enabled: true })) + assert.ok(calls.some(([endpoint, payload]) => endpoint === 'social/face-rooms' && payload?.poll === true)) }) test('#114: in-chat dock lists drafts with Approve all; others see a waiting line', () => { @@ -1823,8 +1928,8 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.equal(peerMark.props['data-image'], 'https://example.test/coder.png') assert.equal(peerMark.props['data-size'], 48) assert.notEqual(mineMark.props['data-bot-id'], peerMark.props['data-bot-id']) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief · Me'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder · Bob'))) assert.equal(mine.children.some(n => n.props?.className === 'fs-sub' || (typeof n.props?.className === 'string' && n.props.className.includes('fs-tag'))), false) assert.equal(peer.children.some(n => n.props?.className === 'fs-sub'), false) assert.equal(wait.some(n => n.props['data-flybot'] === 'face-owner'), false) @@ -1883,7 +1988,7 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch cursor = 0 const liveNodes = liveClient.walk(livePlugin.FaceToFaceGroup({ actions: {} })) assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@writer.alice.home')) - assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer'))) + assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer · Alice'))) assert.equal(liveNodes.some(n => n.props['data-flybot'] === 'face-owner'), false) assert.equal(liveNodes.some(n => Array.isArray(n.children) && n.children.includes('Alice')), false)