From 5dc6aaf32146eb1c11a0eed8307fa5e7aede2190 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 03:44:48 +0800 Subject: [PATCH] Name the link that keeps a removed friend's row (#1001) Removing one of a friend's Bots only said the owner is not unfriended, so a friend who stays in the list because another of their Bots is still shared had no explanation. The removal question now names those Bots: "Bob stays a friend: still shared through Writer." It uses the names the roster already carries, and a friend with no other Bot listed keeps the previous sentence unchanged. --- .../1001-friend-row-names-the-link.changed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 19 +++++++++++++++---- plugins/social/test/social.test.js | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1001-friend-row-names-the-link.changed.md diff --git a/changelog.d/1001-friend-row-names-the-link.changed.md b/changelog.d/1001-friend-row-names-the-link.changed.md new file mode 100644 index 000000000..68f0fceeb --- /dev/null +++ b/changelog.d/1001-friend-row-names-the-link.changed.md @@ -0,0 +1 @@ +Removing one of a friend's Bots now says which link keeps the friend in the list: the question names the Bots of theirs that are still shared ("Bob stays a friend: still shared through Writer."), instead of only saying the owner is not unfriended and leaving the row unexplained ([#1001](https://github.com/ThinkFlowLab/FlyBot/issues/1001)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 15551ba7e..320b41e83 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -132,7 +132,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181). - **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (`shownLook` in `look.js`): a Bot with no stored shape or color gets the id-picked default of core `characters.js` (`defaultShapeOf` / `defaultColorOf`, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape as `BotMark` draws them. A new identity gets its look before `registerOnce` returns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (`remote: false`, local id), the Bot list handle, or the `joinedAs` of this join (`mineBots`), and always uses the roster look (#182). Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. - **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`). Bot Social's `list_bot_grants` marks that grant `reply_only` (bot-social #23), which `normalizeGrant` keeps as `replyOnly` (with `side`); when present it is the only rule (#495): `replyOnly: true` is talk-through and `replyOnly: false` is always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant in `accessRows`. Only a server that does not send the field falls back to the guess: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`. `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per Bot** (#995): `accessRows` keys a grant that names a Bot (its `member` differs from the person's home) by that Bot, labels it from the name Bot Social sent for it in `list_bot_grants` (`normalizeGrant` keeps `botName` / `ownerName`; a server that sends no name falls back to `Owner (@bot.home)`, never two identical person rows), and the row's Revoke takes exactly that grant — so one friend running two Bots is two rows and taking one back leaves the other. A grant that names no Bot keeps the per-person row it had, and a row still carries `grantIds`, which `social/revoke-grant` revokes one after another: grants of one Bot to one person (two invites) are that Bot's row, and #387's own-row talk-through grant is never in another row's `grantIds`. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. -- **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper (Bob) from your list? Your own Bots stay shared. Bob can share it again.` · **Remove** · **Keep**, #230, #496; never the browser `confirm()`), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You already have Helper (Bob).”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. +- **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper (Bob) from your list? Your own Bots stay shared. Bob can share it again.` · **Remove** · **Keep**, #230, #496; never the browser `confirm()`); when the roster still lists another Bot of that friend, the question names the link that keeps the friend instead of leaving the surviving row unexplained (`Bob stays a friend: still shared through Writer.`, #1001; the names are the roster's own, so a friend with no other Bot listed keeps the sentence above), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You already have Helper (Bob).”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#496:** re-accepting an invite for a Bot this home already has is a result, not an error. On `409 relationship_exists` `social/join-invite` answers `{ alreadyHave: true, status: 'active', bot, botName, ownerName, restored: [{ handle, name, owner }] }`. Bot Social's 409 names no Bot, so the plugin reads the public invite info (`GET /v1/invites/{code}`, `inviteInfo` in `mcp-client.js`, sent without a token) for the invite's Bot and owner. `restored` lists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral text `You already have Chief (SelfA1233).` (Bot first, owner in parentheses, #384), or `Chief (SelfA1233) is back in your list.` when something really came back; never red. Without a home (legacy) the join still throws, with the neutral `You already have this friend's Bot.`. **#1000:** that path — and a `/join_invite` that answers `regranted: true` on a relationship that already exists — also grants the joining Bot back to the inviter's Bot (`grant_bot_to_friend`, `chat.public`), because Bot Social applies neither half of an invite again on an existing relationship: the inviter's first line to the joining Bot was held as an unknown recipient until that side shared again. It is the half only the joining home can create, so it is best effort — a server without the tool or REST route leaves the accept successful and `sharedBack` absent — and the Add friend line says ` is shared back, so they can reach you too.` instead of only `You already have …`. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. diff --git a/plugins/social/client.js b/plugins/social/client.js index 0313ba731..074daae8c 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3098,7 +3098,7 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } - function FriendChatSettings({ bot, actions } = {}) { + function FriendChatSettings({ bot, actions, roster } = {}) { const remote = bot?.remote const home = remote?.home const handle = remote?.handle @@ -3127,6 +3127,17 @@ window.__ModuleLoader__.load({ } const named = remote.homeName || remote.owner || '' const who = named || home || 'Your friend' + // #1001: a friend stays in the list while any of their Bots is still shared with any of mine, so + // removing this one leaves the friend behind with no reason given. Name the link that keeps them: + // the other Bots of the same friend the roster still lists. With none, there is nothing to name and + // the question stays the one #139 pinned. Copy only: the roster's own names, no new data. + const stillLinked = (Array.isArray(roster?.remoteBots) ? roster.remoteBots : []) + .filter(other => typeof other?.handle === 'string' && other.handle !== handle && (other.home ?? other.handle) === (home ?? handle)) + .map(other => String(other.name ?? '').trim()) + .filter(name => name !== '') + const keepLine = stillLinked.length > 0 + ? `${who} stays a friend: still shared through ${stillLinked.join(', ')}.` + : `Your own Bots stay shared. ${who} can share it again.` // Asked inline, like Leave on a face room (#230), not with the browser's confirm box. const removeBot = async () => { setAsking(false) @@ -3150,7 +3161,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-hint', 'data-flybot': 'friend-auto-hint' }, `Your Bots' replies to ${who} go out without asking you first. What you type is not affected.`)), h('div', { className: 'fs-friend-remove', 'data-flybot': 'friend-remove-row' }, asking ? h('span', { className: 'fs-leave-ask', 'data-flybot': 'remove-friend-bot-confirm' }, - h('span', { className: 'fs-hint' }, `Remove ${botWithOwner(bot.name, named)} from your list? Your own Bots stay shared. ${who} can share it again.`), + h('span', { className: 'fs-hint' }, `Remove ${botWithOwner(bot.name, named)} from your list? ${keepLine}`), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'remove-friend-bot-yes', onClick: () => { void removeBot() } }, 'Remove'), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'remove-friend-bot-no', onClick: () => setAsking(false) }, 'Keep')) : h('button', { @@ -3166,11 +3177,11 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } - function BotSocialDetails({ bot, actions } = {}) { + function BotSocialDetails({ bot, actions, roster } = {}) { useStyles() if (bot?.remote) { return h('div', { 'data-flybot': 'bot-social-details' }, - h(FriendChatSettings, { bot, actions })) + h(FriendChatSettings, { bot, actions, roster })) } return h(OwnBotSharing, { bot }) } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 867d6327b..7d5564c45 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -6930,6 +6930,21 @@ test('#139: Remove asks once (inline, #230); Keep leaves the Bot', () => { assert.equal(hidden.length, 0) }) +test('#1001: removing one of a friend\'s Bots says which link keeps the friend', () => { + const { walk, pluginOf } = loadSocialClient(async (endpoint) => (endpoint === 'social/settings' ? { autoAccept: {} } : {})) + const chief = { name: 'Chief', remote: { handle: '@chief.bob.home', home: '@bob.home', homeName: 'Bob' } } + const writer = { name: 'Writer', remote: { handle: '@writer.bob.home', home: '@bob.home', homeName: 'Bob' } } + // Another Bot of the same friend is still listed: the friend stays because of that link, and the + // question says so instead of only saying the owner is not unfriended. + const both = walk(pluginOf([false, false, false, true, '']).FriendChatSettings({ bot: chief, roster: { remoteBots: [{ ...chief.remote, name: 'Chief' }, { ...writer.remote, name: 'Writer' }] } })) + const text = both.find(n => n.props['data-flybot'] === 'remove-friend-bot-confirm').children.flatMap(c => c.children ?? [c]).join(' ') + assert.match(text, /Remove Chief \(Bob\) from your list\? Bob stays a friend: still shared through Writer\./) + // No other Bot of theirs: nothing to name, so the copy stays the one #139 pins. + const alone = walk(pluginOf([false, false, false, true, '']).FriendChatSettings({ bot: chief, roster: { remoteBots: [{ ...chief.remote, name: 'Chief' }] } })) + const aloneText = alone.find(n => n.props['data-flybot'] === 'remove-friend-bot-confirm').children.flatMap(c => c.children ?? [c]).join(' ') + assert.match(aloneText, /Your own Bots stay shared\. Bob can share it again\./) +}) + test('#138: join success copy names the person and the Bot', () => { const src = readFileSync(new URL('../client.js', import.meta.url), 'utf8') assert.match(src, /shared \$\{shared\} with you/)