From 18de19d114823d2f63d3b82fbac5cecac7f1e6d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:02:49 +0000 Subject: [PATCH] Show friends and sharing on the Bot Social settings page (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Bot Social only had the server form. Once the server is connected it now lists friends and links each local Bot to People with access. Add friend still opens the existing + menu panel. Co-authored-by: Danyang Chen --- changelog.d/1074-settings-friends.added.md | 1 + core/client.js | 10 ++ core/src/client/i18n.js | 10 ++ docs/subsystems/core-client.md | 1 + docs/subsystems/social.md | 8 +- patches/ds-bot/0001-core-fixes.patch | 12 +- patches/ds-bot/0008-client-bundle.patch | 12 +- plugins/social/client.js | 121 ++++++++++++++++++- plugins/social/test/social.test.js | 132 ++++++++++++++++++++- 9 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1074-settings-friends.added.md diff --git a/changelog.d/1074-settings-friends.added.md b/changelog.d/1074-settings-friends.added.md new file mode 100644 index 000000000..a8d55a888 --- /dev/null +++ b/changelog.d/1074-settings-friends.added.md @@ -0,0 +1 @@ +Settings → Bot Social now lists friends and links each of your Bots to People with access, so sharing and revoke are findable without opening each Bot first. Add friend stays on the + menu, and the same panel is also on that page ([#1074](https://github.com/ThinkFlowLab/FlyBot/issues/1074)). diff --git a/core/client.js b/core/client.js index fa346d38d..877d08ff2 100644 --- a/core/client.js +++ b/core/client.js @@ -272,6 +272,16 @@ var ZH = { "You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.": "你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。", "Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.": "当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。", "Your Bots stop being shared with friends until you share them again on the new server.": "在新服务器上重新分享之前,你的 Bot 不再与好友共享。", + // Settings → Bot Social friends and sharing (#1074) + "Friends": "好友", + "No friends yet.": "还没有好友。", + "Could not load friends.": "好友没有加载出来。", + "Accept them in Add friend.": "到「添加好友」里接受。", + "Waiting for them to accept": "等待对方接受", + "Same panel as the + menu.": "和侧栏的 + 菜单是同一个面板。", + "People with access": "可访问的人", + "People with access to {name}": "可访问 {name} 的人", + "No Bots to share yet.": "还没有可分享的 Bot。", "This is the server you already use.": "这就是你正在用的服务器。", "Switching…": "正在切换…", "Switch to this server": "切换到这台服务器", diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index df96f30f9..a5d19a462 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -236,6 +236,16 @@ const ZH = { 'You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.': '你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。', 'Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.': '当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。', 'Your Bots stop being shared with friends until you share them again on the new server.': '在新服务器上重新分享之前,你的 Bot 不再与好友共享。', + // Settings → Bot Social friends and sharing (#1074) + 'Friends': '好友', + 'No friends yet.': '还没有好友。', + 'Could not load friends.': '好友没有加载出来。', + 'Accept them in Add friend.': '到「添加好友」里接受。', + 'Waiting for them to accept': '等待对方接受', + 'Same panel as the + menu.': '和侧栏的 + 菜单是同一个面板。', + 'People with access': '可访问的人', + 'People with access to {name}': '可访问 {name} 的人', + 'No Bots to share yet.': '还没有可分享的 Bot。', 'This is the server you already use.': '这就是你正在用的服务器。', 'Switching…': '正在切换…', 'Switch to this server': '切换到这台服务器', diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 64b2e192a..75445c6a8 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -24,6 +24,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | File | Role | | --- | --- | | `core/src/client/` | Client sources → build to `core/client.js`. | +| `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | | `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). The shell ids differ by version — one `stats` footer in 0.2.0-rc.2, the counts split into an `activity` pill next to `usage` in 0.2.1-alpha.1 — so `statsDocks` registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); `usage` stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. | | `core/src/client/room-invite-preview.js` | The one room-invite preview card (#876). `invitePreviewOf` says only what a server reply can honestly say (a name cleaned at fixed point, the lead, `n / m in the room`, the deadline), `inviteCountdown` words the deadline, `RoomInvitePreview` draws the card from a caller-supplied `h`; `kind` renames the `data-flybot` hooks, so the face-room paste flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) render the same tree instead of two copies of the markup. Registered on `__flybot__` in `flybot-slots.js`, because a classic-script plugin cannot import it. Tests: `core/test/room-invite-preview.test.js`. | diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 9b10da360..9ece24538 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -34,7 +34,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseInviteLink` / `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code and its QR, then Leave / Enter chat), open room **Invite by link** card (link QR, Copy link, Withdraw link); a local group's Members card **Invite by link** row (`LocalInviteRow`, slot `flybot.room.members`: Create link / New link, the link's QR, Copy link, m:ss countdown, Withdraw link, through the core `room-invite*` endpoints via `__flybot__.host`); the Face-to-face dialog's link field and scan also take a group link of another FlyBot (`/r/@handle/ticket`): `room-invite-preview` → the shared preview card (**Join group**) → `room-invite-ask` with the picked Bots, one sentence per refusal (#876); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`): the server form, and once connected a **Friends** list plus one **People with access** link per local Bot (#1074). `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code and its QR, then Leave / Enter chat), open room **Invite by link** card (link QR, Copy link, Withdraw link); a local group's Members card **Invite by link** row (`LocalInviteRow`, slot `flybot.room.members`: Create link / New link, the link's QR, Copy link, m:ss countdown, Withdraw link, through the core `room-invite*` endpoints via `__flybot__.host`); the Face-to-face dialog's link field and scan also take a group link of another FlyBot (`/r/@handle/ticket`): `room-invite-preview` → the shared preview card (**Join group**) → `room-invite-ask` with the picked Bots, one sentence per refusal (#876); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -77,7 +77,7 @@ 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 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. 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). 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). 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) @@ -146,6 +146,10 @@ Creating a group opens its Session with zero turns, and the host draws no sessio - **A server is an identity.** The default server keeps its files. Any other server gets `flybot-social.json.` (credentials; the same suffix `startSocial` already used for backups, `tag` = first 8 hex of the sha-256 of the normalized address) and `flybot-social-settings..json`; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (the `member` identity, the `autoApprove` marks and the person/room grants, which are Bot Social ids and handles) are parked in `flybot-social-bots..json` (no token) and cleared before the new server starts, so a stale grant to `@bob.home` can never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (`remote.publish`); face-to-face rooms already hosted stay as local transcripts, but nothing polls them. - **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are marked `left` here — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). Switching back does not reopen them: a room this home left stays a past room (`faceSnapshotPast`, #182), and a line to it is refused (`bot/room-ended`). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). +### Friends and sharing on the Bot Social page (#1074) + +Settings → **Bot Social** lists friends and shares once the server is connected (`state: 'connected'`). Until then the page is only the server form: a missing, connecting, or failed server draws neither section. **Friends** reads `social/overview` and shows each accepted friend (name, handle), each incoming request (accept it in Add friend), and each outgoing request that is still waiting. A name is one line: controls and angle brackets are stripped and the text is capped at 80, so a value that skipped the host's `cleanText` still cannot break the row. A failed read says so and is not drawn as an empty list. **Add friend** closes Settings and opens the sidebar panel the **+** menu already opens (`openAddFriend`); that **+** row stays. **Sharing** lists this home's Bots from `social/status` (the roster's own Bots, never a friend's, when that read fails or comes back empty) with the Main Bot first. Each **People with access** button opens that Bot's settings (`openSettings('bots', id)`) and scrolls to `ManageAccess`, where Revoke stays; opening Bot settings any other way does not scroll. Tests: `plugins/social/test/social.test.js` (`#1074`). + ### Add friend invite input (#310) `social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. The client (`plugins/social/client.js`) shows those sentences as written (#347): `errorText` relabels a message as "This invite was already used" only for the raw server words (`invite_unavailable`, `invite is used`, `invite_used`), never for the not-found sentence, which itself mentions "already used" as one possible cause; `run()` keeps its "… failed:" prefix for every other message and drops it only for the "already used" sentence; the neutral already-have sentence shows without error styling (#496). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 8602bd4b5..cdbf50fe1 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -591,7 +591,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connectors: '连接器', 'Search connectors': '搜索连接器', 'No connectors': '暂无连接器', -@@ -93,6 +93,49 @@ const ZH = { +@@ -93,6 +93,59 @@ const ZH = { 'Connecting…': '正在连接…', 'Could not connect: {error}': '连接失败:{error}', 'Connected as {account}': '已连接:{account}', @@ -634,6 +634,16 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.': '你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。', + 'Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.': '当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。', + 'Your Bots stop being shared with friends until you share them again on the new server.': '在新服务器上重新分享之前,你的 Bot 不再与好友共享。', ++ // Settings → Bot Social friends and sharing (#1074) ++ 'Friends': '好友', ++ 'No friends yet.': '还没有好友。', ++ 'Could not load friends.': '好友没有加载出来。', ++ 'Accept them in Add friend.': '到「添加好友」里接受。', ++ 'Waiting for them to accept': '等待对方接受', ++ 'Same panel as the + menu.': '和侧栏的 + 菜单是同一个面板。', ++ 'People with access': '可访问的人', ++ 'People with access to {name}': '可访问 {name} 的人', ++ 'No Bots to share yet.': '还没有可分享的 Bot。', + 'This is the server you already use.': '这就是你正在用的服务器。', + 'Switching…': '正在切换…', + 'Switch to this server': '切换到这台服务器', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9a6ab9c96..f8e7bef0f 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -146,7 +146,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,49 @@ +@@ -129,6 +129,59 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -189,6 +189,16 @@ diff --git a/client.js b/client.js + "You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.": "你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。", + "Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.": "当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。", + "Your Bots stop being shared with friends until you share them again on the new server.": "在新服务器上重新分享之前,你的 Bot 不再与好友共享。", ++ // Settings → Bot Social friends and sharing (#1074) ++ "Friends": "好友", ++ "No friends yet.": "还没有好友。", ++ "Could not load friends.": "好友没有加载出来。", ++ "Accept them in Add friend.": "到「添加好友」里接受。", ++ "Waiting for them to accept": "等待对方接受", ++ "Same panel as the + menu.": "和侧栏的 + 菜单是同一个面板。", ++ "People with access": "可访问的人", ++ "People with access to {name}": "可访问 {name} 的人", ++ "No Bots to share yet.": "还没有可分享的 Bot。", + "This is the server you already use.": "这就是你正在用的服务器。", + "Switching…": "正在切换…", + "Switch to this server": "切换到这台服务器", diff --git a/plugins/social/client.js b/plugins/social/client.js index 5032f8ac0..01cac31ce 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3059,6 +3059,13 @@ window.__ModuleLoader__.load({ const ACCESS_POLL_MS = 5000 const ACCESS_NOTE = 'One row per Bot. Revoke one without affecting the rest. Default: nobody, Chief included.' + // Settings → Bot Social asks to land on this card (#1074). One shot: a normal open of Bot settings does not scroll. + let peopleAccessBot = null + function openPeopleAccess(actions, botId) { + if (typeof actions?.openSettings !== 'function' || !botId) return + peopleAccessBot = botId + actions.openSettings('bots', botId) + } function ManageAccess({ bot, onGrants }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') @@ -3079,6 +3086,13 @@ window.__ModuleLoader__.load({ timer?.unref?.() return () => clearInterval(timer) }, [bot?.id]) + useEffect(() => { + if (!bot?.id || peopleAccessBot !== bot.id || typeof document?.querySelector !== 'function') return undefined + peopleAccessBot = null + const node = document.querySelector('[data-flybot="manage-access"]') + if (typeof node?.scrollIntoView === 'function') node.scrollIntoView({ block: 'start' }) + return undefined + }, [bot?.id]) // Sharing's hint mentions Revoke only once someone has access (#499). const granted = (view?.grants ?? []).length useEffect(() => { onGrants?.(granted) }, [granted, onGrants]) @@ -3419,6 +3433,31 @@ window.__ModuleLoader__.load({ // Bot Social capability ids in words (#421). An id FlyBot does not know stays out of the line and // only shows in the tooltip, with the raw ids, for diagnostics. const FEATURE_WORDS = { typed_by_owner: 'typing as you', mentions: '@mentions', presence: 'online status' } + // A peer name on this page is one line: the overview already cleans it, and a value that skipped that still cannot break the row (#1074). + const settingsName = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) + const friendLabel = (friend) => { + const name = settingsName(friend?.name) + const owner = settingsName(friend?.owner) + const handle = settingsName(friend?.handle) + const who = name || handle + if (who && owner && owner !== who && !who.includes(owner)) return `${who} (${owner})` + return who + } + const shareBotOf = (bot) => (bot && bot.remote !== true && typeof bot.id === 'string' && bot.id + ? { id: bot.id, name: settingsName(bot.name) || 'Bot', isMain: bot.isMain === true } + : null) + // The host's Row does not forward a data attribute, so this page's rows are the same markup with a hook. + function settingsRow({ label, hint, flybot, kind, children } = {}) { + return h('div', { + className: 'bt-set-row', + ...(flybot ? { 'data-flybot': flybot } : {}), + ...(kind ? { 'data-kind': kind } : {}), + }, + h('div', { className: 'bt-set-copy' }, + h('span', null, label), + hint ? h('span', { className: 'bt-set-hint' }, hint) : null), + children ? h('div', { className: 'bt-set-control' }, children) : null) + } const featureLine = (features, t) => { const ids = Array.isArray(features) ? features.map(String) : [] if (!ids.length) return t('No optional features listed.') @@ -3429,9 +3468,10 @@ window.__ModuleLoader__.load({ : others === 1 ? t('Supports 1 optional feature') : t('Supports {count} optional features', { count: others }) return h('span', { title: ids.join(', '), 'data-flybot': 'server-features' }, text) } - function SocialServerSettings({ t: given, Section, Row } = {}) { + function SocialServerSettings({ t: given, Section, Row, actions, roster } = {}) { useStyles() const t = given ?? ((text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? ''))) + // state order: view, loadError, live, form, address, name, checked, busy, error, circle, circleError, shareBots const [view, setView] = useState(null) const [loadError, setLoadError] = useState(null) const [live, setLive] = useState(null) @@ -3441,6 +3481,9 @@ window.__ModuleLoader__.load({ const [checked, setChecked] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [circle, setCircle] = useState(null) + const [circleError, setCircleError] = useState('') + const [shareBots, setShareBots] = useState(null) const refresh = async () => { try { setView(await call('social/server', {})); setLoadError(null) } catch (e) { setLoadError(errorText(e)) } } @@ -3458,6 +3501,27 @@ window.__ModuleLoader__.load({ call('social/server-check', {}).then(info => { if (!off) setLive({ ok: true, ...info }) }, e => { if (!off) setLive({ ok: false, error: errorText(e) }) }) return () => { off = true } }, [view?.server, view?.state === 'connected']) + // Friends and the Bots that can be shared (#1074). Hidden until this server is actually connected. + useEffect(() => { + if (!view?.server || view.state !== 'connected') return undefined + let off = false + const fromRoster = () => (Array.isArray(roster?.bots) ? roster.bots : []).map(shareBotOf).filter(Boolean) + call('social/overview', {}).then((value) => { + if (off) return + setCircle({ + friends: Array.isArray(value?.friends) ? value.friends : [], + incoming: Array.isArray(value?.incoming) ? value.incoming : [], + outgoing: Array.isArray(value?.outgoing) ? value.outgoing : [], + }) + setCircleError('') + }, (e) => { if (!off) setCircleError(errorText(e)) }) + call('social/status', {}).then((value) => { + if (off) return + const listed = (Array.isArray(value?.bots) ? value.bots : []).map(shareBotOf).filter(Boolean) + setShareBots(listed.length ? listed : fromRoster()) + }, () => { if (!off) setShareBots(fromRoster()) }) + return () => { off = true } + }, [view?.server, view?.state]) const platform = live?.ok ? live : view?.platform const open = (value = '') => { setForm(true); setAddress(value); setChecked(null); setError(null) } const close = () => { setForm(false); setChecked(null); setError(null); setBusy(false) } @@ -3477,6 +3541,26 @@ window.__ModuleLoader__.load({ const source = view.source === 'settings' ? t('Set here') : view.source === 'config' ? t('Profile default') : t('Not set') const status = (STATE_TEXT[view.state] ?? STATE_TEXT.connecting)(t, view) const quick = [...(view.defaultServer && view.source === 'settings' ? [view.defaultServer] : []), ...view.known].filter((item, at, all) => all.indexOf(item) === at) + const connected = view.state === 'connected' + const friendHint = (friend, kind) => { + const handle = settingsName(friend?.handle) + if (kind === 'friend') return handle || undefined + const sentence = kind === 'incoming' ? t('Accept them in Add friend.') : t('Waiting for them to accept') + return [handle, sentence].filter(Boolean).join(' · ') + } + const friendRows = circle ? ['friend', 'incoming', 'outgoing'].flatMap(kind => (circle[kind === 'friend' ? 'friends' : kind] ?? []).map((friend) => { + const label = friendLabel(friend) + if (!label) return null + return settingsRow({ flybot: 'settings-friend', kind, label, hint: friendHint(friend, kind) }) + }).filter(Boolean)) : [] + const friendStatus = circleError + ? settingsRow({ flybot: 'settings-friends-status', kind: 'error', label: t('Could not load friends.'), hint: circleError }) + : circle === null + ? settingsRow({ flybot: 'settings-friends-status', kind: 'loading', label: t('Loading…') }) + : friendRows.length === 0 + ? settingsRow({ flybot: 'settings-friends-status', kind: 'empty', label: t('No friends yet.') }) + : null + const orderedBots = (shareBots ?? []).slice().sort((a, b) => Number(b.isMain) - Number(a.isMain) || a.name.localeCompare(b.name)) return h('div', { 'data-flybot': 'social-server' }, h(Section, { title: t('Bot Social server') }, h(Row, { label: view.server ?? t('No server'), hint: status }, @@ -3505,7 +3589,40 @@ window.__ModuleLoader__.load({ checked && !checked.same ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, 'data-flybot': 'server-confirm', onClick: () => void change() }, busy ? t('Switching…') : t('Switch to this server')) : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || address.trim() === '', 'data-flybot': 'server-check', onClick: () => void check() }, busy ? t('Checking…') : t('Check'))))) - : null) + : null, + connected ? h(Section, { title: t('Friends') }, + friendStatus, + ...friendRows, + settingsRow({ + label: t('Add friend'), + hint: t('Same panel as the + menu.'), + children: h('button', { + type: 'button', + className: 'bt-soft bt-soft-sm', + 'data-flybot': 'settings-add-friend', + onClick: () => { + // The panel is in the sidebar, which the Settings dialog covers. + if (typeof actions?.closeSettings === 'function') actions.closeSettings() + openAddFriend() + }, + }, t('Add friend')), + })) : null, + connected ? h(Section, { title: t('Sharing') }, + shareBots === null ? settingsRow({ label: t('Loading…') }) : null, + ...orderedBots.map(bot => settingsRow({ + label: bot.name, + hint: bot.isMain ? t('Main Bot') : undefined, + children: h('button', { + type: 'button', + className: 'bt-soft bt-soft-sm', + 'data-flybot': 'settings-people-access', + 'data-bot': bot.id, + 'aria-label': t('People with access to {name}', { name: bot.name }), + disabled: typeof actions?.openSettings !== 'function', + onClick: () => openPeopleAccess(actions, bot.id), + }, t('People with access')), + })), + shareBots && orderedBots.length === 0 ? settingsRow({ label: t('No Bots to share yet.') }) : null) : null) } let cache = { at: 0, list: [] } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index b9332c05f..bc88c2ac8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8401,7 +8401,7 @@ test('server controller: per-server files, a hung start is replaced and its late } }) -// state order in SocialServerSettings: view, loadError, live, form, address, name, checked, busy, error +// state order in SocialServerSettings: view, loadError, live, form, address, name, checked, busy, error, circle, circleError, shareBots const serverView = (extra = {}) => ({ server: 'https://a.test', source: 'config', defaultServer: 'https://a.test', displayName: 'Me', state: 'connected', handle: '@me.home', error: null, canChange: true, known: [], ...extra }) const pageProps = { Section: 'section', Row: 'row', t: (text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) } const nodeBy = (nodes, id) => nodes.find(node => node.props?.['data-flybot'] === id) @@ -8503,6 +8503,136 @@ test('Settings → Bot Social page: every English line it passes to t() has a Ch assert.ok(Object.hasOwn(DICTIONARY.zh, 'Bot Social')) }) +// A social client whose effects run and whose setState sticks, so a Settings page can load. +function liveSocialClient(callImpl) { + const { registered, entries, walk, sandbox } = loadSocialClient(callImpl) + const buckets = new Map() + let bucket = [] + let index = 0 + const plugin = registered[0].factory(() => ({ + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), + Fragment: 'F', + useEffect: fn => { fn?.() }, + useState: (init) => { + const at = index++ + if (bucket[at] === undefined) bucket[at] = typeof init === 'function' ? init() : init + const store = bucket + const slot = at + return [store[slot], (value) => { store[slot] = typeof value === 'function' ? value(store[slot]) : value }] + }, + useRef: v => ({ current: v }), + useMemo: f => f(), + })) + const render = (key, draw) => { + if (!buckets.has(key)) buckets.set(key, []) + bucket = buckets.get(key) + index = 0 + return walk(draw()) + } + const textOf = (node) => (typeof node === 'string' || typeof node === 'number' ? String(node) : (node?.children ?? []).map(textOf).join('')) + return { plugin, entries, sandbox, render, textOf } +} + +const settingsFriends = [ + { relationshipId: 'r-home', name: 'Bob', handle: '@bob.home' }, + { relationshipId: 'r-bot', name: 'Chief', owner: 'SelfB', handle: '@chief.selfb.home' }, + { relationshipId: 'r-dirty', name: 'Eve\n