From ff6aee9ac5f39046d4ebead20be8b2aefca5acbc Mon Sep 17 00:00:00 2001 From: Danyang Chen <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:35 +0800 Subject: [PATCH 1/2] Past rooms: list everyone, show each row's time, open from the row, remember the fold (#304) --- changelog.d/304-face-past-rooms.fixed.md | 1 + docs/subsystems/social.md | 10 +- plugins/social/client.js | 60 ++++++++++-- plugins/social/src/face-rooms.js | 4 +- plugins/social/src/social.js | 34 ++++++- plugins/social/test/social.test.js | 118 ++++++++++++++++++++--- 6 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 changelog.d/304-face-past-rooms.fixed.md diff --git a/changelog.d/304-face-past-rooms.fixed.md b/changelog.d/304-face-past-rooms.fixed.md new file mode 100644 index 000000000..c5054f8e3 --- /dev/null +++ b/changelog.d/304-face-past-rooms.fixed.md @@ -0,0 +1 @@ +Past rooms list everyone who was in a face-to-face room (a home that stayed no longer drops the person who left), each row shows when the room ended, clicking anywhere on the row opens it, and the Past rooms fold stays open or closed across a reload ([#304](https://github.com/ThinkFlowLab/FlyBot/issues/304)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dfca66657..3d3937617 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -76,13 +76,13 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - A held send is never without a place (#371): the approval carries the Bot Social identity that wrote it (`approvalView` → `botId`; `pendingApprovals` maps it to the local Bot as `fromId`) and Bot Social's DLP categories (`holdReasons`: `commitment`, `pii.phone`, `pii.email`, `money`, `owner.sensitive_term`, `calendar.details`; the plain "all outbound needs approval" reason is not one). When the friend's chat is hidden, gone, home-level or not in the roster (`listedFriendChat`), the sender Bot's own chat draws the card; a listed friend chat alone shows it (#278). The card's second line reads `Held for your approval: contains commitment language` (host `t`, so zh in a zh UI) and the held text stays on the card, the bubble says only that it waits. The sender Bot's row also takes the wait dot (the `chatApprovals` provider answers for local Bots by `fromId`). No message is posted into a Bot's chat for this: it would reach the Bot's model. - Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, right-aligned like a bubble — not full chat width; a Bot's held reply to a friend has its card on that reply's bubble instead, invariant 8): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Reject** buttons plus a quiet **Always approve** link on one row for the owner (#260). **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or face-room `social/face-auto`) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The social strip "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 open room's overview — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. -- Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms** with **Open** only. 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 social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend** and **Face-to-face room** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend opens the join panel (handle, invite link, or code). Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). +- Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms**; the whole row opens the room (#304). 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 social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend** and **Face-to-face room** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend opens the join panel (handle, invite link, or code). Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). ### Face-to-face waiting grid, leave, and social + (#124, #125, #126) - Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: the 6-digit room number at the top, avatar on top; a joined member leads with the person (`data-flybot=face-owner`, the tile name) and the Bot they brought on a second line (`data-flybot=face-bot`), so two Chiefs are told apart (#229); picker tiles show the Bot name only. **Leave** and **Enter chat** share one footer row. Join copy uses owner + Bot name (`Chief · BoxA` / `BoxA's Chief`), never `BoxA's BoxA` or generic `BoxA's Bot`. Setup copy says you wait together until someone enters the chat. Ended joining says **Joining has ended**, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). -- **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId`, keep the title on one line with the full title as hover text (#304), and have one primary action (**Open**); live rows are the main-list RoomRow (people mark) with **Leave** on the open room overview. -- #192: **Leave** keeps the hosted room, marked `left` (`upsertFace`), so **Open** in Past rooms finds it already hosted and shows the read-only transcript with `You left this room.` (before, `dropFace` removed it and Open built an empty room the browser did not have yet). A past room that falls off the 20 kept entries is dropped then. +- **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId`, keep the title on one line with the full title as hover text (#304), and the whole row is the one action: clicking it (or Enter / Space, `role=button`) opens the read-only room, with no separate **Open** button; live rows are the main-list RoomRow (people mark) with **Leave** on the open room overview. +- #192: **Leave** keeps the hosted room, marked `left` (`upsertFace`), so opening it from Past rooms finds it already hosted and shows the read-only transcript with `You left this room.` (before, `dropFace` removed it and Open built an empty room the browser did not have yet). A past room that falls off the 20 kept entries is dropped then. - #193: the account-menu handle row overrides the menu's full-width button rule so the handle text stays visible next to **Copy**. - #194: a face room title shows one time: Bot Social's `created_at`, else the joining deadline minus the 10-minute window (`faceRoomCreatedAt`), the same before and after Enter chat and on every home. - #195: the Add friend paste button reads **Accept invite**; an accepted Bot invite posts a toast and closes the panel at once. @@ -106,7 +106,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Friend Bot details overview is Auto-send plus a muted **Remove this Bot** link (not a primary button). The DSH settings-shell chrome around empty local panes is unchanged. The host opens that overview via the compact remote Bot settings sheet (`remote-bot-settings`); the sidebar gear is a sibling of the friend's `bt-row`, not nested inside it (#166). The Auto-send box holds that setting only, and Remove this Bot sits in its own row below it; the sheet takes focus when it opens and closes on Escape, and inside a tree item the row with its gear shrinks to fit (`.bt-tree-item>.bt-row-wrap`), so focusing the gear never scrolls the sidebar sideways (#274). - Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). -- Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past-room **Open** button keeps its own spacing from avatar and chip (#170). +- Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. - The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. @@ -165,7 +165,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### 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. -- 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. +- 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`). - 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). - 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). diff --git a/plugins/social/client.js b/plugins/social/client.js index 75c2b69c1..121ec74ac 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -155,6 +155,10 @@ window.__ModuleLoader__.load({ .fs-face-item[data-past] .fs-face-row{gap:8px} .fs-face-item[data-past] .fs-chip-status{margin-left:4px} .fs-face-item[data-past] .fs-face-actions{margin-left:8px;gap:6px} +.fs-face-time{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));white-space:nowrap;font-variant-numeric:tabular-nums} +.fs-face-item[data-past] .fs-face-row{border-radius:6px} +.fs-face-item[data-past] .fs-face-row[data-open]:hover{background:var(--bt-hover,rgba(119,119,119,.09))} +.fs-face-item[data-past] .fs-face-row[data-open]:focus-visible{outline:2px solid var(--bt-accent,#4d6bfe);outline-offset:-2px} .fs-face-head{display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%;padding:2px 0 6px} .fs-face-top{display:flex;align-items:center;gap:10px;min-width:0} .fs-face-head .fs-face-auto-wrap{min-width:0} @@ -823,6 +827,21 @@ window.__ModuleLoader__.load({ } return bits.join(' · ') } + // The time a Past row shows (#304): when the room ended here, else when it started (or was + // created). Today reads `14:05`, an earlier day `Oct 19`; the hover text has the full time. + const FACE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + function faceRowTime(room, today = new Date()) { + const ended = Date.parse(room?.endedAt || '') + const deadline = Date.parse(room?.expiresAt || '') + const started = Date.parse(room?.startedAt || '') || Date.parse(room?.createdAt || '') || (Number.isFinite(deadline) ? deadline - 10 * 60_000 : NaN) + const at = Number.isFinite(ended) ? ended : started + if (!Number.isFinite(at)) return undefined + const when = new Date(at) + const hhmm = `${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}` + const day = `${FACE_MONTHS[when.getMonth()]} ${when.getDate()}` + const sameDay = when.getFullYear() === today.getFullYear() && when.getMonth() === today.getMonth() && when.getDate() === today.getDate() + return { text: sameDay ? hhmm : day, full: `${Number.isFinite(ended) ? 'Ended' : 'Started'} ${day} ${hhmm}` } + } function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) { const [busy, setBusy] = useState('') const [note, setNote] = useState(null) @@ -853,23 +872,36 @@ window.__ModuleLoader__.load({ } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy('') } } + const title = shownFaceTitle(room) + const time = past ? faceRowTime(room) : undefined + // A past row is one button: clicking anywhere on it opens the read-only room (#304). + const rowOpens = past && openable return h('div', { className: 'fs-face-item', 'data-flybot': 'face-room', 'data-status': room.status, 'data-past': past || undefined }, h('div', { className: 'fs-face-row', 'data-open': openable ? true : undefined, - onClick: openable ? () => open() : undefined, + onClick: openable ? () => (busy ? undefined : open()) : undefined, + ...(rowOpens ? { + role: 'button', tabIndex: 0, 'aria-label': `Open ${title}`, 'aria-busy': busy ? true : undefined, + onKeyDown: (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault?.() + return busy ? undefined : open() + }, + } : {}), }, 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), // One line; the full title on hover when the narrow sidebar cuts it (#304). - h('span', { className: 'fs-name', title: shownFaceTitle(room) }, shownFaceTitle(room)), + h('span', { className: 'fs-name', title }, title), + time ? h('span', { className: 'fs-face-time', 'data-flybot': 'face-time', title: time.full }, time.text) : null, h('span', { className: 'fs-chip fs-chip-status', 'data-flybot': 'face-status', 'data-status': faceChip(room) }, faceChipLabel(room)), - h('span', { className: 'fs-face-actions', onClick: event => event.stopPropagation() }, - past - ? (openable ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => open() }, 'Open') : null) - : h(ConfirmLeave, { busy, onLeave: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }))), + past + ? null + : h('span', { className: 'fs-face-actions', onClick: event => event.stopPropagation() }, + h(ConfirmLeave, { busy, onLeave: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }))), note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } @@ -1290,9 +1322,21 @@ window.__ModuleLoader__.load({ } return out } + // Past rooms stays open or folded across a reload (#304). + const PAST_OPEN_KEY = 'flybot.social.pastRoomsOpen' + function readPastOpen() { + try { return globalThis.localStorage?.getItem(PAST_OPEN_KEY) === '1' } catch { return false } + } + function writePastOpen(open) { + try { globalThis.localStorage?.setItem(PAST_OPEN_KEY, open ? '1' : '0') } catch { /* storage unavailable */ } + } function FaceRooms({ actions, enabled = true, roster, remoteChats } = {}) { const [rooms, setRooms] = useState([]) - const [pastOpen, setPastOpen] = useState(false) + const [pastOpen, setPastOpen] = useState(readPastOpen()) + const togglePast = () => { + writePastOpen(!pastOpen) + setPastOpen(!pastOpen) + } const unique = uniqueFaceRooms(rooms) const past = unique.filter(isPastFace) const load = (poll = true) => call('social/face-rooms', { poll }).then(value => { @@ -1316,7 +1360,7 @@ window.__ModuleLoader__.load({ }) return h('div', { 'data-flybot': 'face-rooms' }, 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('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: togglePast }, h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), 'Past rooms', h('span', { className: 'fs-count' }, String(past.length))), diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index ba79be4d7..143b100c9 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -248,7 +248,9 @@ export function trustedFaceWho(from) { } export function faceRoomTitle(snapshot = {}, { code, room } = {}) { - const members = snapshot.members ?? room?.participants ?? [] + // Everyone who was in the room, like the core title (#182): a poll after someone left must not + // drop them from the title its Past row keeps (#304). + const members = [...(snapshot.members ?? []), ...(room?.participants ?? [])] const at = faceRoomCreatedAt({ createdAt: snapshot.createdAt, expiresAt: snapshot.expiresAt || room?.face?.expiresAt, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 06c887c89..d845599a7 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2593,9 +2593,23 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } throw new FaceRoomError(`${bot.name} could not be registered on Bot Social. Try again.`, 'register_failed') } + // A past room lists everyone who was in it, like its header (#304); a live room lists who is still there. + function faceViewMembers(room, past) { + const all = room.participants ?? [] + if (!past) return all.filter(p => p.leftSeq === undefined) + const byKey = new Map() + for (const p of all) { + const key = String(p.handle || p.id || '').toLowerCase() + if (!key) continue + const seen = byKey.get(key) + if (!seen || (seen.leftSeq !== undefined && p.leftSeq === undefined)) byKey.set(key, p) + } + return [...byKey.values()] + } function viewHostedFace(room, extra = {}) { if (!room) return undefined - const live = (room.participants ?? []).filter(p => p.leftSeq === undefined) + const status = extra.status || room.face?.status + const live = faceViewMembers(room, status === 'closed' || status === 'left') return { roomId: room.id, faceRoomId: room.face?.roomId, @@ -2611,6 +2625,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ownerName: p.ownerName, remote: p.remote === true, roomOnly: p.roomOnly === true || p.published !== true, + ...(p.leftSeq !== undefined ? { left: true } : {}), 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 } : {}), @@ -2857,8 +2872,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const before = (settings.pastFaceRooms ?? []).find(item => item.faceRoomId === room.faceRoomId) // Who left keeps "Left": a poll that later finds the room gone must not relabel it "Closed" (#304). const status = before?.status === 'left' ? 'left' : (room.status || 'left') + // When the room ended here, kept from the first time it was remembered: the time its Past row shows (#304). + const endedAt = before?.endedAt || room.endedAt || new Date(now()).toISOString() const next = [ - { ...room, status, ...(before?.joinCode && !room.joinCode ? { joinCode: before.joinCode } : {}) }, + { ...room, status, endedAt, ...(before?.joinCode && !room.joinCode ? { joinCode: before.joinCode } : {}) }, ...(settings.pastFaceRooms ?? []).filter(item => item.faceRoomId !== room.faceRoomId), ] settings.pastFaceRooms = next.slice(0, 20) @@ -2988,7 +3005,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp status: seed.status === 'open' ? 'closed' : (seed.status || 'closed'), expiresAt: seed.expiresAt, startedAt: seed.startedAt, - members: seed.members ?? [], + // Who had left before the room ended is listed for the title, not seated again. + members: (seed.members ?? []).filter(member => member?.left !== true), localBots: bringable(), }) if (!room?.id) throw new Error('Could not open that room') @@ -2998,13 +3016,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp disposers.push(flybot.api.handle('social/face-open', async (payload) => openFaceRoom(payload), { read: true })) disposers.push(flybot.api.handle('social/face-rooms', async (payload) => { if (payload?.poll !== false) await pollFaceRooms().catch(() => { /* a failed poll shows the rooms as last polled */ }) + const pastById = new Map((settings.pastFaceRooms ?? []).filter(item => item?.faceRoomId).map(item => [item.faceRoomId, item])) const rooms = Object.entries(settings.faceRooms).map(([faceRoomId, meta]) => { const room = flybot.rooms.ofFace?.(faceRoomId) - return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode } + const endedAt = pastById.get(faceRoomId)?.endedAt + return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode, ...(endedAt ? { endedAt } : {}) }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode } }).filter(Boolean) const liveIds = new Set(rooms.map(room => room.faceRoomId).filter(Boolean)) for (const past of settings.pastFaceRooms ?? []) { - if (past?.faceRoomId && !liveIds.has(past.faceRoomId)) rooms.push(past) + if (!past?.faceRoomId || liveIds.has(past.faceRoomId)) continue + // The kept room still knows everyone who was in it: a row stored with only who was left lists them all (#304). + const hosted = flybot.rooms.ofFace?.(past.faceRoomId) + const everyone = hosted ? viewHostedFace(hosted, { status: past.status })?.members : undefined + rooms.push(everyone?.length > (past.members?.length ?? 0) ? { ...past, members: everyone } : past) } return { rooms, bots: localFaceBots(), autoApproveFaceRooms: await faceAutoApprove().catch(() => false) } }, { read: true })) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 65ea73571..83fa5dd25 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1880,11 +1880,21 @@ test('when the last peer leaves, the face room moves to Past rooms', async (t) = // This home did not leave: the room ended around it, so it reads Closed, not Left (#304) ... assert.equal(past.status, 'closed') assert.match(past.name, / · 123456$/) + // ... it still names the person who left, like the room header, and lists their Bot (#304) ... + assert.equal(past.name, 'Face-to-face · Danyang, Bob · 123456') + assert.deepEqual(past.members.map(m => [m.handle, m.left === true]), [['@chief.danyang.home', false], ['@coder.bob.home', true]]) + // ... and carries when it ended, for the row's time. + assert.ok(Number.isFinite(Date.parse(past.endedAt))) assert.equal(listed.rooms.filter(item => item.faceRoomId === 'frm_123456').length, 1) // ... and it leaves on Bot Social too, so it stops polling the room and releases its seat. assert.equal(room.status, 'closed', 'left on Bot Social') assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'closed') assert.match(ctx.flybot.rooms.ofFace('frm_123456').name, / · 123456$/) + // Later lists keep the same people and the first end time. + const again = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.find(item => item.faceRoomId === 'frm_123456') + assert.equal(again.name, 'Face-to-face · Danyang, Bob · 123456') + assert.equal(again.members.length, 2) + assert.equal(again.endedAt, past.endedAt) }) test('#304: a failed automatic leave is tried again on the next poll', async (t) => { @@ -2825,7 +2835,7 @@ test('Face-to-face past card titles rebuild from every member, not a stale auto assert.equal(String(title?.children?.join?.('') ?? ''), 'Face-to-face · UiA1415, UiB1415 · Oct 17 14:38') }) -test('Past face room Open resolves the hosted session through face-open', async () => { +test('#304: clicking a Past room row opens it through face-open; there is no separate Open button', async () => { const opened = [] const calls = [] const { walk, pluginOf } = loadSocialClient(async (endpoint, payload) => { @@ -2838,12 +2848,20 @@ test('Past face room Open resolves the hosted session through face-open', async past: true, actions: { openSession: id => opened.push(id) }, })) - const btn = tree.find(n => n.type === 'button' && n.children.includes('Open')) - assert.ok(btn) - await btn.props.onClick() + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Open')), false) + const row = tree.find(n => n.props.className === 'fs-face-row') + assert.equal(row.props.role, 'button') + assert.equal(row.props.tabIndex, 0) + assert.equal(row.props['aria-label'], 'Open Hall') + await row.props.onClick() assert.equal(calls[0][0], 'social/face-open') assert.equal(calls[0][1].faceRoomId, 'frm_old') assert.deepEqual(opened, ['g-fixed']) + // The keyboard opens it too. + await row.props.onKeyDown({ key: 'Enter', preventDefault() {} }) + await row.props.onKeyDown({ key: 'Tab', preventDefault() {} }) + assert.equal(calls.length, 2) + assert.deepEqual(opened, ['g-fixed', 'g-fixed']) }) test('Face-to-face room card titles include people and a distinguisher, not Face-to-face room', () => { @@ -2910,11 +2928,12 @@ test('Face-to-face past rooms sit under a Past rooms fold; live rooms stay off t assert.ok(opened.some(n => n.props['data-status'] === 'closed')) assert.ok(opened.some(n => n.props['data-status'] === 'left')) assert.equal(opened.filter(n => n.props['data-flybot'] === 'face-room' && n.props['data-past']).length, 2) - assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Open')).length, 2) + // Each past row is the button that opens it (#304). + assert.equal(opened.filter(n => n.props.className === 'fs-face-row' && n.props.role === 'button').length, 2) + assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Open')).length, 0) assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 0) assert.equal(opened.some(n => n.type === 'button' && n.children.includes('Start')), false) assert.equal(opened.some(n => n.props['data-status'] === 'open'), false) - assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.fs-face-item\[data-past\] \.fs-face-actions\{[^}]*margin-left:8px/) const pastOnly = walk(pluginOf([ [{ faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }], @@ -3047,7 +3066,7 @@ test('#306: in an open room Leave sits on the status line, a row away from the A assert.match(src, /\.fs-dialog-back\{[^}]*z-index:6[0-9]/) }) -test('an ended matching window sits under Past rooms with Open, not Leave', () => { +test('an ended matching window sits under Past rooms and opens from its row, not Leave', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const rooms = [ { faceRoomId: 'frm_end', roomId: 'g4', name: 'Old meet', status: 'candidate', expiresAt: '2000-01-01T00:00:00.000Z', members: [] }, @@ -3057,11 +3076,11 @@ test('an ended matching window sits under Past rooms with Open, not Leave', () = const ended = tree.find(n => n.props['data-flybot'] === 'face-room' && n.props['data-status'] === 'candidate') assert.ok(ended?.props['data-past']) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('ended'))) - assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) + assert.ok(tree.some(n => n.props.className === 'fs-face-row' && n.props.role === 'button')) assert.equal(tree.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 0) }) -test('Face-to-face past rooms hide duplicate ids and keep Open as the only action', () => { +test('Face-to-face past rooms hide duplicate ids and opening the row is the only action', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const rooms = [ { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }, @@ -3069,8 +3088,8 @@ test('Face-to-face past rooms hide duplicate ids and keep Open as the only actio ] const tree = walk(pluginOf([rooms, true]).FaceRooms({})) assert.equal(tree.filter(n => n.props['data-flybot'] === 'face-room').length, 1) - assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) - assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Leave')), false) + assert.ok(tree.some(n => n.props.className === 'fs-face-row' && n.props.role === 'button')) + assert.equal(tree.some(n => n.type === 'button' && (n.children.includes('Open') || n.children.includes('Leave'))), false) }) test('Add friend mints and copies an invite link in one click', async () => { @@ -5413,6 +5432,83 @@ test('#304: Past rooms keep the room\'s number and never show the joining deadli assert.match(title({ name: '', status: 'closed', expiresAt: '2026-10-18T08:42:00.000Z', startedAt: '2026-10-18T08:35:00.000Z', members }), new RegExp(`${hhmm}$`)) }) +test('#304: each Past room row shows its time: when it ended, else when it started', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const timeOf = room => walk(pluginOf([]).FaceRoomCard({ room, past: true })).find(n => n.props['data-flybot'] === 'face-time') + const pad = n => String(n).padStart(2, '0') + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + // Ended today: the clock time; the hover names the full moment. + const today = new Date() + today.setHours(9, 5, 0, 0) + const ended = timeOf({ faceRoomId: 'f1', name: 'Hall', status: 'closed', members: [], endedAt: today.toISOString(), startedAt: '2026-01-02T03:04:00.000Z' }) + assert.equal(ended.children.join(''), '09:05') + assert.equal(ended.props.title, `Ended ${months[today.getMonth()]} ${today.getDate()} 09:05`) + // An earlier day: the date; the hover keeps the time. + const old = new Date(2026, 9, 18, 21, 40) + const earlier = timeOf({ faceRoomId: 'f2', name: 'Hall', status: 'left', members: [], endedAt: old.toISOString() }) + assert.equal(earlier.children.join(''), 'Oct 18') + assert.equal(earlier.props.title, 'Ended Oct 18 21:40') + // Never closed here (an ended joining window, or a row kept before end times): when it started. + const start = new Date(2026, 9, 17, 8, 30) + assert.equal(timeOf({ faceRoomId: 'f3', name: 'Hall', status: 'closed', members: [], startedAt: start.toISOString() }).props.title, 'Started Oct 17 08:30') + const deadline = new Date(2026, 9, 16, 12, 10) + assert.equal(timeOf({ faceRoomId: 'f4', name: 'Hall', status: 'candidate', members: [], expiresAt: deadline.toISOString() }).props.title, `Started Oct 16 ${pad(12)}:00`) + // No time known: no empty slot. Live rows show no time. + assert.equal(timeOf({ faceRoomId: 'f5', name: 'Hall', status: 'closed', members: [] }), undefined) + assert.equal(walk(pluginOf([]).FaceRoomCard({ room: { faceRoomId: 'f6', name: 'Hall', status: 'open', members: [], startedAt: start.toISOString() } })).some(n => n.props['data-flybot'] === 'face-time'), false) +}) + +test('#304: a Past room title lists everyone who was in the room, including who left', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const tree = walk(pluginOf([]).FaceRoomCard({ + room: { + faceRoomId: 'frm_1', name: 'Face-to-face · SelfA0056 · 482915', status: 'closed', + members: [ + { handle: '@chief.selfa.home', ownerName: 'SelfA0056' }, + { handle: '@chief.selfb.home', ownerName: 'SelfB0056', left: true }, + ], + }, + past: true, + })) + assert.equal(tree.find(n => n.props.className === 'fs-name').children.join(''), 'Face-to-face · SelfA0056, SelfB0056 · 482915') + assert.equal(tree.filter(n => n.props['data-flybot'] === 'face-card-member').length, 2) +}) + +test('#304: Past rooms remembers whether it is open across a reload', () => { + const { walk, registered, sandbox } = loadSocialClient(async () => ({})) + const store = new Map() + sandbox.localStorage = { getItem: key => (store.has(key) ? store.get(key) : null), setItem: (key, value) => store.set(key, String(value)) } + const rooms = [{ faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }] + // A page render with the real initial fold value: the room list is preset, the fold is read from storage. + const page = () => { + let i = 0 + const plugin = registered[0].factory(() => ({ + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), Fragment: 'F', + useEffect: () => {}, useRef: v => ({ current: v }), useMemo: f => f(), + useState: v => [i++ === 0 ? rooms : v, () => {}], + })) + const tree = walk(plugin.FaceRooms({})) + return { head: tree.find(n => n.props.className === 'fs-past-head'), rows: tree.filter(n => n.props['data-flybot'] === 'face-room') } + } + // Nothing stored: folded. + let shown = page() + assert.equal(shown.head.props['aria-expanded'], false) + assert.equal(shown.rows.length, 0) + // Opening it is kept, so the next page load shows it open. + shown.head.props.onClick() + shown = page() + assert.equal(shown.head.props['aria-expanded'], true) + assert.equal(shown.rows.length, 1) + // Folding it is kept too. + shown.head.props.onClick() + assert.equal(page().head.props['aria-expanded'], false) + // Storage that throws (private mode) just starts folded. + sandbox.localStorage = { getItem() { throw new Error('denied') }, setItem() { throw new Error('denied') } } + shown = page() + assert.equal(shown.head.props['aria-expanded'], false) + shown.head.props.onClick() +}) + test('#191: an ended face room says so in plain view and names the person who left', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const plugin = pluginOf([]) From d4d414abd408720b0f6f8bf32eb038f117df6cca Mon Sep 17 00:00:00 2001 From: Danyang Chen <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:07:56 +0800 Subject: [PATCH 2/2] Past rows: one CSS rule for the row band, drop the unused Open-button spacing (#304) --- plugins/social/client.js | 4 +--- plugins/social/test/social.test.js | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/social/client.js b/plugins/social/client.js index 121ec74ac..2ceaa29a4 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -152,11 +152,9 @@ window.__ModuleLoader__.load({ .fs-chip{flex:none;font-size:11px;line-height:14px;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-tag-ink,rgba(20,20,20,.56));white-space:nowrap} .fs-chip-status{min-width:44px;text-align:center;font-weight:600;letter-spacing:.01em} .fs-chip-status[data-status=open],.fs-chip-status[data-status=closed],.fs-chip-status[data-status=left]{font-size:12px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2,rgba(20,20,20,.72));background:var(--bt-hover,rgba(119,119,119,.12))} -.fs-face-item[data-past] .fs-face-row{gap:8px} +.fs-face-item[data-past] .fs-face-row{gap:8px;border-radius:6px} .fs-face-item[data-past] .fs-chip-status{margin-left:4px} -.fs-face-item[data-past] .fs-face-actions{margin-left:8px;gap:6px} .fs-face-time{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));white-space:nowrap;font-variant-numeric:tabular-nums} -.fs-face-item[data-past] .fs-face-row{border-radius:6px} .fs-face-item[data-past] .fs-face-row[data-open]:hover{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-face-item[data-past] .fs-face-row[data-open]:focus-visible{outline:2px solid var(--bt-accent,#4d6bfe);outline-offset:-2px} .fs-face-head{display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%;padding:2px 0 6px} diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 83fa5dd25..fddce8d49 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -5456,6 +5456,10 @@ test('#304: each Past room row shows its time: when it ended, else when it start // No time known: no empty slot. Live rows show no time. assert.equal(timeOf({ faceRoomId: 'f5', name: 'Hall', status: 'closed', members: [] }), undefined) assert.equal(walk(pluginOf([]).FaceRoomCard({ room: { faceRoomId: 'f6', name: 'Hall', status: 'open', members: [], startedAt: start.toISOString() } })).some(n => n.props['data-flybot'] === 'face-time'), false) + // The time is quiet and fixed-width; a past row reads as one clickable band. + const css = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(css, /\.fs-face-time\{flex:none;[^}]*white-space:nowrap/) + assert.match(css, /\.fs-face-item\[data-past\] \.fs-face-row\[data-open\]:hover\{background:/) }) test('#304: a Past room title lists everyone who was in the room, including who left', () => {