Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1638-approval-dock-room-card.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@
function qrRsGenerator(degree) {
let poly = [1]
for (let i = 0; i < degree; i += 1) {
const next = new Array(poly.length + 1).fill(0)

Check warning on line 685 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let j = 0; j < poly.length; j += 1) {
next[j] ^= poly[j]
next[j + 1] ^= qrMul(poly[j], QR_GF_EXP[i])
Expand All @@ -693,7 +693,7 @@
}
function qrRsRemainder(bytes, degree) {
const gen = qrRsGenerator(degree)
const res = new Array(bytes.length + degree).fill(0)

Check warning on line 696 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let i = 0; i < bytes.length; i += 1) res[i] = bytes[i]
for (let i = 0; i < bytes.length; i += 1) {
const factor = res[i]
Expand Down Expand Up @@ -767,8 +767,8 @@
}
function qrSkeleton(version) {
const size = version * 4 + 17
const modules = Array.from({ length: size }, () => new Array(size).fill(0))

Check warning on line 770 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
const fixed = Array.from({ length: size }, () => new Array(size).fill(false))

Check warning on line 771 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
const set = (x, y, dark) => { modules[y][x] = dark ? 1 : 0; fixed[y][x] = true }
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy += 1) {
Expand Down Expand Up @@ -1024,7 +1024,7 @@
return out
}
const cleanRoomName = (value, fallback = '') => stripUntrustedMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 1027 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -1068,7 +1068,7 @@
let matrix
try { matrix = qrMatrix(text) } catch { return null }
const size = matrix.size
return h('div', { className: 'fs-qr', 'data-flybot': kind, 'data-modules': size, ...(attrs ?? {}) },

Check warning on line 1071 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
h('svg', {
className: 'fs-qr-svg', width: size, height: size, viewBox: `0 0 ${size} ${size}`,
role: 'img', 'aria-label': label, shapeRendering: 'crispEdges',
Expand Down Expand Up @@ -2430,11 +2430,13 @@
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)
Expand Down
86 changes: 86 additions & 0 deletions plugins/social/test/approval-dock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__ }
})
Loading