From 84eb8bea506790f48bb4ee68ee1fac616db95497 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:54:55 +0000 Subject: [PATCH] Mark a vanished face room Closed so it leaves the active list A room that drops off Bot Social was filed under Past rooms while the hosted room stayed open, so the sidebar kept it beside the Closed row. Mark that room closed (a room this home already left stays Left) and use Bot Social's closed_at as the Past row time when the snapshot has one. Fixes #1070 Co-authored-by: Danyang Chen --- ...osed-face-room-leaves-active-list.fixed.md | 1 + docs/subsystems/social.md | 3 +- plugins/social/src/social.js | 26 +++++++- plugins/social/test/social.test.js | 61 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1070-closed-face-room-leaves-active-list.fixed.md diff --git a/changelog.d/1070-closed-face-room-leaves-active-list.fixed.md b/changelog.d/1070-closed-face-room-leaves-active-list.fixed.md new file mode 100644 index 000000000..e09d8c053 --- /dev/null +++ b/changelog.d/1070-closed-face-room-leaves-active-list.fixed.md @@ -0,0 +1 @@ +A face room that Bot Social no longer lists is marked Closed on the hosted room, so it leaves the active sidebar list and stays as one Past rooms row. When Bot Social sends `closed_at`, that time is the row's time on every home ([#1070](https://github.com/ThinkFlowLab/FlyBot/issues/1070)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 1d320da11..c18412eb9 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -176,9 +176,8 @@ Creating a group opens its Session with zero turns, and the host draws no sessio ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): `faceWaitCode` and `shownFaceTitle` refuse a stored value that is not exactly six digits (`1234567` never reads as `123456`) and fall back to the code the owner typed, or to the creation time. -- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. This room is closed.` The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). +- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. This room is closed.` The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). A number that is not empty and not exactly six digits keeps **Join** disabled and shows **Enter 6 digits** under the field (#1052). Six digits clear that line and enable Join once a Bot is picked; an empty field shows nothing. - - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - **Open** on a Past room calls `social/face-open` (`plugins/social/src/social.js`): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come from `plugins/social/src/face-title.js`, with participant owners in stable handle order, so both homes show the same name (#172). - Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). While a FlyBot window is on screen (`flybot.watched()`, #497) a second timer also reads the inbox at the `facePollMs` cadence, so a friend's first line, the held reply's **Waiting for approval** card, the Inbox count and the row's unread badge reach the open window within a few seconds instead of up to 15 s later (the self-test's "only after a reload"). It skips a tick the shared timer already covers (a live face room) or that follows an inbox read by less than half its period, and it reads nothing with no window open, so Bot Social sees the faster reads only while someone watches. The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. The social strip still refreshes past rooms about every 5s. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index ad03ace06..7f56e1178 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -3631,11 +3631,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp settings.faceRooms[snapshot.roomId].seen = seen const lone = Boolean(past) && neverShared(flybot.rooms.ofFace?.(snapshot.roomId) ?? room) if (past && !lone) { + // Bot Social's close time, when it sent one: both homes then show the same Past-rooms time (#1070). + const closedAt = Date.parse(snapshot.closedAt || '') rememberPastFace(viewHostedFace(flybot.rooms.ofFace?.(snapshot.roomId) ?? room, { faceRoomId: snapshot.roomId, localRoomId: room.id, status: past.status, name: title, + ...(Number.isFinite(closedAt) ? { endedAt: snapshot.closedAt } : {}), })) } saveSettings() @@ -3730,8 +3733,27 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (known.has(serverId)) continue const hosted = flybot.rooms.ofFace?.(serverId) 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 }) + else { + // Who already left keeps Left. Everyone else reads Closed: the room ended around them (#304). + const status = hosted?.face?.status === 'left' ? 'left' : 'closed' + rememberPastFace(viewHostedFace(hosted, { faceRoomId: serverId, localRoomId: meta?.localRoomId, status, joinCode: meta?.joinCode }) + || { faceRoomId: serverId, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status, members: [], joinCode: meta?.joinCode }) + // The sidebar drops a face room only when the hosted status is closed or left. A Past row + // alone leaves the room in the active list as well (#1070). A failed mark stays in + // faceRooms so the next poll tries again. + if (hosted?.id && hosted.face?.status !== 'left' && hosted.face?.status !== 'closed') { + try { + await flybot.rooms.upsertFace?.({ + roomId: serverId, + status: 'closed', + ...(meta?.joinCode ? { joinCode: meta.joinCode } : {}), + }) + } catch (error) { + warn('face-room not marked closed', error) + continue + } + } + } delete settings.faceRooms[serverId] dropRoomRemotes(serverId) forgetSentRoles(serverId) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index fb0e325aa..819abebde 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2524,6 +2524,67 @@ test('when a face room drops off Bot Social, the local transcript stays openable assert.ok(opened.roomId) }) +test('#1070: a room that drops off Bot Social is Closed once and leaves the active list', 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() + server.face.peer('582914') + await call('flybot/social/face-join', { start: true, code: '582914' }) + await call('flybot/social/face-start', { faceRoomId: 'frm_582914' }) + assert.equal(ctx.flybot.rooms.ofFace('frm_582914').face.status, 'open') + // The other side closed the room: Bot Social no longer lists it. This home did not Leave. + server.face.rooms.delete('frm_582914') + const listed = (await call('flybot/social/face-rooms', { poll: true })).value + const copies = listed.rooms.filter(room => room.faceRoomId === 'frm_582914') + assert.equal(copies.length, 1, 'one Past-rooms row') + assert.equal(copies[0].status, 'closed') + assert.ok(copies[0].members.some(member => member.handle === '@coder.bob.home'), 'who was there stays on the row') + // The sidebar hides a face room only when the hosted status is closed or left. + assert.equal(ctx.flybot.rooms.ofFace('frm_582914').face.status, 'closed', 'leaves the active list') + assert.match(ctx.flybot.rooms.ofFace('frm_582914').name, / · 582914$/) + const again = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.filter(room => room.faceRoomId === 'frm_582914') + assert.equal(again.length, 1, 'a later poll does not add a second row') + assert.equal(again[0].endedAt, copies[0].endedAt) + assert.equal(ctx.flybot.rooms.ofFace('frm_582914').face.status, 'closed', 'a later poll does not reopen it') +}) + +test('#1070: Past rooms uses the server close time when Bot Social sends one', 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() + server.face.peer('846271') + await call('flybot/social/face-join', { start: true, code: '846271' }) + await call('flybot/social/face-start', { faceRoomId: 'frm_846271' }) + const room = server.face.rooms.get('frm_846271') + room.status = 'closed' + room.closed_at = '2026-10-25T13:22:00.000Z' + const listed = (await call('flybot/social/face-rooms', { poll: true })).value + const copies = listed.rooms.filter(item => item.faceRoomId === 'frm_846271') + assert.equal(copies.length, 1) + assert.equal(copies[0].status, 'closed') + assert.equal(copies[0].endedAt, '2026-10-25T13:22:00.000Z') + assert.equal(ctx.flybot.rooms.ofFace('frm_846271').face.status, 'closed') + server.face.rooms.delete('frm_846271') + const again = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.filter(item => item.faceRoomId === 'frm_846271') + assert.equal(again.length, 1, 'dropping off the list does not add a second row') + assert.equal(again[0].endedAt, '2026-10-25T13:22:00.000Z') + assert.equal(ctx.flybot.rooms.ofFace('frm_846271').face.status, 'closed') +}) + test('face-to-face leave after matching ends still moves the room to Past rooms', async (t) => { const { ctx, call, stop } = await team() t.after(stop)