From 93653c04ddd2f56a4a745a026dc80da2fbdb063c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 22:33:05 +0800 Subject: [PATCH] #992: give the share outcome and the list error their own slots The sharing card kept one note for two failures, so a share click erased the list error and its retry, and the next poll erased the share's message. They are two slots now, drawn side by side; each success settles only its own. --- changelog.d/992-share-two-notes.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 27 +++++--- plugins/social/test/social.test.js | 85 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 changelog.d/992-share-two-notes.fixed.md diff --git a/changelog.d/992-share-two-notes.fixed.md b/changelog.d/992-share-two-notes.fixed.md new file mode 100644 index 000000000..08ce076b7 --- /dev/null +++ b/changelog.d/992-share-two-notes.fixed.md @@ -0,0 +1 @@ +The Sharing card now shows both facts at once: a failed **Share** and a failed friend-list read each keep their own message, so clicking Share no longer erases the list error and its **Try again**, and the five-second refresh no longer erases what the share told you. A successful refresh settles only the list error, and a successful share settles only its own line ([#992](https://github.com/ThinkFlowLab/FlyBot/issues/992)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 6efc067e0..355a0bf61 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Join or create by number or link** (zh 面对面建群或用链接加入, #892; it covers both ways in: a six-digit number entered together, or a group invite link pasted or scanned) and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. That entry opens the same screen, titled the same, whose copy names both ways and whose link field reads *Or paste a group invite link* (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973); the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987): the status slot carries a source, the retry button belongs to the list read alone and it is the only failure a successful read clears, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists who has that Bot, in which groups, and **Revoke**. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Join or create by number or link** (zh 面对面建群或用链接加入, #892; it covers both ways in: a six-digit number entered together, or a group invite link pasted or scanned) and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. That entry opens the same screen, titled the same, whose copy names both ways and whose link field reads *Or paste a group invite link* (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973); the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists who has that Bot, in which groups, and **Revoke**. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/plugins/social/client.js b/plugins/social/client.js index 0c08a7241..a49ff9e04 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3149,7 +3149,10 @@ window.__ModuleLoader__.load({ useStyles() const [friends, setFriends] = useState(null) const [busy, setBusy] = useState('') + // #992: two slots, not one. The friend-list read and the Share click are different failures; + // a share error must not overwrite the list error the retry belongs to. const [note, setNote] = useState(null) + const [listError, setListError] = useState(null) const readSeq = useRef(0) const load = async () => { if (!bot?.id) return @@ -3161,18 +3164,19 @@ window.__ModuleLoader__.load({ // about a roster that no longer exists, so it is dropped, never written (#984). if (readSeq.current !== seq || shareListEpoch !== epoch) return setFriends(Array.isArray(next?.friends) ? next.friends : []) - // #987: the note has a source. A successful read settles the list, never a share's error. - setNote(previous => (previous?.source === 'list' ? null : previous)) + // #992: a successful read settles the list error only; the share's note is its own. + setListError(null) } catch (e) { if (readSeq.current !== seq || shareListEpoch !== epoch) return // Keep the rows a successful read established: a failed read established nothing, so the // card says it failed and offers a retry instead of claiming nobody is left (#984). - setNote({ tone: 'error', source: 'list', text: errorText(e) }) + setListError(errorText(e)) } } useEffect(() => { setFriends(null) setNote(null) + setListError(null) void load() if (typeof setInterval !== 'function') return undefined const timer = setInterval(() => { if (typeof document === 'undefined' || !document.hidden) void load() }, 5000) @@ -3193,13 +3197,13 @@ window.__ModuleLoader__.load({ ...(friend.peerBotId ? { friendBotId: friend.peerBotId } : {}), ...(friend.ownerId ? { ownerId: friend.ownerId } : {}), }) - setNote({ tone: 'ok', source: 'share', text: result?.message || `Shared ${bot.name}.` }) + setNote({ tone: 'ok', text: result?.message || `Shared ${bot.name}.` }) const next = await call('social/share-friends', { botId: bot.id }).catch(() => null) if (next) setFriends(Array.isArray(next.friends) ? next.friends : []) else setFriends(list => (list ?? []).filter(item => friendKey(item) !== friendKey(friend))) } catch (e) { - // #987: the share's failure speaks for the share; it is not the list's error. - setNote({ tone: 'error', source: 'share', text: errorText(e) }) + // #992: the share's failure has its own slot; the list error and its retry stay put. + setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy('') } } const list = friends ?? [] @@ -3208,7 +3212,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-name' }, 'Share with a friend'), h('div', { className: 'fs-hint' }, `One click shares ${bot.name || 'this Bot'} with someone you already know. No invite link. They can decline; you can Revoke in People with access.`), loading ? h('div', { className: 'fs-hint' }, 'Loading friends…') : null, - !loading && list.length === 0 && note?.source !== 'list' + !loading && list.length === 0 && !listError ? h('div', { className: 'fs-hint', 'data-flybot': 'share-with-friend-empty' }, 'No friends left to share with. Invite someone new above, or they already have this Bot.') : null, list.map(friend => h('div', { key: friendKey(friend), className: 'fs-line', 'data-flybot': 'share-friend-row' }, @@ -3225,10 +3229,11 @@ window.__ModuleLoader__.load({ 'aria-label': `Share ${bot.name || 'this Bot'} with ${friend.name || friend.home}`, onClick: () => { void share(friend) }, }, busy === friendKey(friend) ? 'Sharing…' : 'Share'))), - note ? h('div', { className: 'fs-status', 'data-tone': note.tone, role: 'status' }, note.text, - note.tone === 'error' && note.source === 'list' - ? h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'share-list-retry', onClick: () => { void load() } }, 'Try again') - : null) : null) + note ? h('div', { className: 'fs-status', 'data-tone': note.tone, 'data-flybot': 'share-note', role: 'status' }, note.text) : null, + listError + ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'share-list-error', role: 'status' }, listError, + h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'share-list-retry', onClick: () => { void load() } }, 'Try again')) + : null) } // A friend chat Bot Social paused (#404): a quiet notice, no buttons. There is no turn limit and diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index e00ab2d94..19425b9a1 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -11083,3 +11083,88 @@ test('#987: the empty sentence is suppressed by a list error, not by a share err await panel.settle() assert.equal(panel.has('share-with-friend-empty'), false, 'a list error does suppress it') }) + +// #TBD: two slots, not one. A failed share and a failed friend-list read are different failures; a +// share error must not overwrite the list error the retry belongs to. +test('#992: a failed share does not overwrite the list error', async () => { + let readFails = false + const host = sharingHost(async (endpoint) => { + if (endpoint === 'social/share-friends') { + if (readFails) throw new Error('social/share-friends: no answer from Bot Social') + return { friends: [shareCoder] } + } + if (endpoint === 'social/share-with-friend') throw new Error('social/share-with-friend: Bot Social said no') + return {} + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + assert.ok(panel.has('share-friend-row')) + readFails = true + panel.poll() + await panel.settle() + assert.ok(panel.has('share-list-retry'), 'the failed read offers its retry') + await panel.click('share-friend') + await panel.settle() + assert.match(panel.text(), /no answer from Bot Social/, 'the list error stays after a failed share') + assert.match(panel.text(), /Bot Social said no/, 'the share error is shown too') + assert.ok(panel.has('share-list-retry'), 'and its retry is still there') + readFails = false + await panel.click('share-list-retry') + await panel.settle() + assert.equal(panel.has('share-list-retry'), false, 'a successful read clears the list error') + assert.match(panel.text(), /Bot Social said no/, 'but never the share error') +}) + +test('#992: a failed read does not overwrite the share error either', async () => { + let readFails = false + let friends = [shareCoder] + const host = sharingHost(async (endpoint) => { + if (endpoint === 'social/share-friends') { + if (readFails) throw new Error('social/share-friends: no answer from Bot Social') + return { friends } + } + if (endpoint === 'social/share-with-friend') throw new Error('social/share-with-friend: Bot Social said no') + return {} + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + await panel.click('share-friend') + await panel.settle() + readFails = true + panel.poll() + await panel.settle() + assert.match(panel.text(), /Bot Social said no/, 'the share error survives a later failed read') + assert.match(panel.text(), /no answer from Bot Social/, 'and the read failure is named too') + assert.ok(panel.has('share-list-retry'), 'the retry only the list read gets') +}) + +test('#992: a successful share leaves the list error and its retry alone', async () => { + let readFails = false + const host = sharingHost(async (endpoint) => { + if (endpoint === 'social/share-friends') { + if (readFails) throw new Error('social/share-friends: no answer from Bot Social') + return { friends: [shareCoder] } + } + if (endpoint === 'social/share-with-friend') return { message: 'Shared Writer with Coder (Bob).' } + return {} + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + readFails = true + panel.poll() + await panel.settle() + assert.ok(panel.has('share-list-retry')) + await panel.click('share-friend') + await panel.settle() + assert.match(panel.text(), /Shared Writer with Coder \(Bob\)\./, 'the share reports its own outcome') + assert.match(panel.text(), /no answer from Bot Social/, 'and leaves the list error on screen') + assert.ok(panel.has('share-list-retry'), 'with its retry') +}) + +test('#992: a fresh mount starts with no note of either kind', async () => { + const host = sharingHost(async (endpoint) => (endpoint === 'social/share-friends' ? { friends: [shareCoder] } : {})) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + assert.equal(panel.has('share-note'), false) + assert.equal(panel.has('share-list-error'), false) +})