diff --git a/changelog.d/1638-approval-dock-room-card.fixed.md b/changelog.d/1638-approval-dock-room-card.fixed.md new file mode 100644 index 000000000..454dc460e --- /dev/null +++ b/changelog.d/1638-approval-dock-room-card.fixed.md @@ -0,0 +1 @@ +A room's own pending approval card is no longer announced as "Waiting for the owner's approval": the dock also reads the room's own `pendingApprovals` — the array its `pendingCount` counts — so the one 5 s poll where its own lists are behind stays quiet instead of hiding your own Approve / Reject buttons, while a card it never draws (decided on its reply bubble or in the Bot's chat) keeps the waiting line ([#1638](https://github.com/ThinkFlowLab/FlyBot/issues/1638)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dc7a9b3fb..26fcaca31 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -85,7 +85,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Inline approvals and compact Friends' Bots (#114, #115) - 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 in a face room the writing Bot's own `social/face-auto` switch, #457) 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 — and only once both lists this dock reads (`social/status` and `flybot/approvals`) have answered and neither holds a card for this room (#1607); before that the dock draws nothing, because an empty list is one 5 s poll behind as often as it is another owner's card. 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 one compact checkbox per Bot of mine on the open room's overview (#457) — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. A local hold that failed (`status: 'failed'`) draws as **Not sent** with its error, never Approve / Reject, and offers **Retry** (the core `decide` `retry`) only while trying again could work: the error a sender marks with `noRetry` — a friend's Bot no longer shared with this home, where Bot Social answers `bot_not_found` (#954) — arrives as `retry: false` and the card draws no button, so no click repeats a refusal that cannot change; **Approve all** covers Bot Social holds (`status: 'pending'`) as well as local ones and leaves a failed hold to Retry, and a failed decision says why instead of throwing silently. The "Waiting for the owner's approval" ledger (`settings.approvalNotices`) keeps one entry per still-pending hold and drops a hold's entry as soon as it leaves the pending queue, so the persisted settings file does not grow with every hold the install ever had. +- 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 in a face room the writing Bot's own `social/face-auto` switch, #457) 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 — and only once both lists this dock reads (`social/status` and `flybot/approvals`) have answered, neither holds a card for this room, and the room's own list (`room.pendingApprovals`, the very array its `pendingCount` counts) holds no card this dock would draw — a card decided elsewhere keeps the line, on its own reply bubble (#418) or in the Bot's chat (#254) (#1607, #1638); before that the dock draws nothing, because an empty list is one 5 s poll behind as often as it is another owner's card, and a room's own card is never narrated as another owner's. 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 one compact checkbox per Bot of mine on the open room's overview (#457) — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. A local hold that failed (`status: 'failed'`) draws as **Not sent** with its error, never Approve / Reject, and offers **Retry** (the core `decide` `retry`) only while trying again could work: the error a sender marks with `noRetry` — a friend's Bot no longer shared with this home, where Bot Social answers `bot_not_found` (#954) — arrives as `retry: false` and the card draws no button, so no click repeats a refusal that cannot change; **Approve all** covers Bot Social holds (`status: 'pending'`) as well as local ones and leaves a failed hold to Retry, and a failed decision says why instead of throwing silently. The "Waiting for the owner's approval" ledger (`settings.approvalNotices`) keeps one entry per still-pending hold and drops a hold's entry as soon as it leaves the pending queue, so the persisted settings file does not grow with every hold the install ever had. - 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). Settings → **Bot Social** opens that same panel (#1074); it does not add a second one. 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) diff --git a/plugins/social/client.js b/plugins/social/client.js index d93795b0f..35ab627e2 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -2430,11 +2430,13 @@ window.__ModuleLoader__.load({ return () => claim([]) }, [shownIds, sessionId]) // A card for this room that this chat does not draw is still ours: it is decided on its own bubble - // (#418) or in the Bot's chat. "Waiting for the owner's approval" therefore needs both lists answered - // and neither holding one for this room (#1607); until then the dock stays quiet, never guesses. - const roomIds = new Set([room?.id, room?.face?.roomId].filter(Boolean)) - const thisRoomHeld = held.some(item => item.roomId && roomIds.has(item.roomId)) - const waitingOthers = mine.length === 0 && statusLoaded && listLoaded && !thisRoomHeld && (room?.pendingCount ?? 0) > 0 + // (#418) or in the Bot's chat (#254). "Waiting for the owner's approval" therefore needs both lists + // answered, neither holding one for this room (#1607), and no card in the room's own list that this + // dock would draw. That list is the very array the room's `pendingCount` counts (channel.js), so it + // knows our card one 5 s poll before these two do: that beat stays quiet, never "another owner's" + // (#1638). A card the dock never draws keeps the line, because there the sentence is true. + const roomCards = approvalsForOpenChat((room?.pendingApprovals ?? []).filter(item => !item.inline), session) + const waitingOthers = mine.length === 0 && statusLoaded && listLoaded && roomCards.length === 0 && (room?.pendingCount ?? 0) > 0 if (!mine.length && !waitingOthers) return null const decide = async (item, decision) => { setBusy(item.id) diff --git a/plugins/social/test/approval-dock.test.js b/plugins/social/test/approval-dock.test.js index 27a1e4af7..485cb40ef 100644 --- a/plugins/social/test/approval-dock.test.js +++ b/plugins/social/test/approval-dock.test.js @@ -129,3 +129,89 @@ test('#1607: an answered, empty list keeps the honest "another owner" card for a assert.doesNotMatch(tree, /Approve/, 'no decision is offered for a card we cannot see') } finally { delete globalThis.__flybot__ } }) + +// #1638: the room object the dock is handed already carries `pendingApprovals` — in +// `core/src/host/channel.js` that array is the very one `pendingCount` counts — so the room view knows +// it holds a card while the dock's own two polls are still a beat behind. A card this dock would draw +// is never called somebody else's just because those 5 s lists have not caught up; a card the dock +// never draws (decided on its own bubble or in the Bot's chat) keeps the line, because there the +// sentence is true. +// +// The room's list is Bot Social's own (`plugins/social/src/social.js` `pendingApprovals` through the +// room provider), not the host's `flybot/approvals` wrapper: a held line of this face room is +// `kind: 'face_room_message'` with the room id in `roomId` and no `request` envelope. +const roomLine = () => ({ id: 'a1', kind: 'face_room_message', status: 'pending', roomId: 'frm1', from: 'SelfTest A2', to: 'the room', text: 'hello room' }) +const roomWithCard = () => ({ ...room(), pendingApprovals: [roomLine()] }) + +test('#1638: a room holding its own pending card is never announced as another owner waiting', async () => { + const app = loadDock() + const { state, inst } = harness() + inst.props.room = roomWithCard() + try { + render(app.ChatApprovalDock, inst) + runEffects(inst) + // One beat behind: both of the dock's own lists answer without this room's card (the lagging poll). + state.resolveApprovals([]) + await tick() + const tree = drawn(render(app.ChatApprovalDock, inst)) + assert.doesNotMatch(tree, /Waiting for the owner's approval/, "the room's own card is never narrated as another owner waiting") + assert.doesNotMatch(tree, /\[no-buttons\]/, 'the lagging poll draws no buttonless card') + } finally { delete globalThis.__flybot__ } +}) + +// A card this dock never draws, whatever list names it: a held reply is decided on its own bubble (#418), +// and the dock's own list drops it by that marker (`held`, `!item.inline`). Bot Social's room list carries +// no such marker today (only the host's `localHoldView` sets it), so this pins that the two rules agree if +// one ever arrives: a card already on screen is not silenced as "somebody else's" nor as nothing. +test('#1638: a room card drawn on its own bubble keeps the waiting line', async () => { + const app = loadDock() + const { state, inst } = harness() + inst.props.room = { ...room(), pendingApprovals: [{ ...roomLine(), inline: 's9' }] } + try { + render(app.ChatApprovalDock, inst) + runEffects(inst) + state.resolveApprovals([]) + await tick() + const tree = drawn(render(app.ChatApprovalDock, inst)) + assert.match(tree, /Waiting for the owner's approval/, 'a card drawn on its bubble keeps the honest line here') + assert.doesNotMatch(tree, /Approve/, 'the dock does not decide a card it never draws') + } finally { delete globalThis.__flybot__ } +}) + +// The line is not silenced wholesale, or a room that counts a card this chat never draws would lose +// its only honest voice: our Bot's held direct message to a friend is decided in the Bot's chat +// (#254, #418), and the room's provider counts it by the sender's handle (`social.js` `roomApprovals`), +// so the room holds it while this dock draws nothing (#1607). Marking it "another owner's" would be +// wrong, but "waiting for the owner's approval" is true. +test('#1638: a room card this chat never draws keeps the waiting line', async () => { + const app = loadDock() + const { state, inst } = harness() + // A group room with the Bot that wrote the held line among its participants; the hold is an + // `outbound_message` to a friend, which `pendingApprovals` tags with the local Bot's `fromId`. + inst.props.room = { + id: 'g1', + pendingCount: 1, + pendingApprovals: [{ id: 'b1', kind: 'outbound_message', status: 'pending', from: '@self.home', fromId: 'bot1', to: '@friend.home', text: 'a line to a friend' }], + participants: [{ kind: 'bot', remote: true, handle: '@self.home' }], + } + try { + render(app.ChatApprovalDock, inst) + runEffects(inst) + state.resolveApprovals([]) + await tick() + const tree = drawn(render(app.ChatApprovalDock, inst)) + assert.match(tree, /Waiting for the owner's approval/, 'a card decided elsewhere keeps the honest line') + assert.doesNotMatch(tree, /Approve/, 'no decision is offered where the card is not drawn') + } finally { delete globalThis.__flybot__ } +}) + +// A quiet room (nothing pending) still draws nothing at all. +test('#1638: a room with no pending card and a zero count draws no card', () => { + const app = loadDock() + const { inst } = harness() + inst.props.room = { ...room(), pendingCount: 0, pendingApprovals: [] } + try { + const tree = drawn(render(app.ChatApprovalDock, inst)) + assert.equal(tree, '', 'a quiet room draws no card') + } finally { delete globalThis.__flybot__ } +})