From 8db890d7322a704185475267fb479d5a2fb6605b Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:11:28 +0800 Subject: [PATCH] Keep "People with access" to one readable line The hint joined every room a share is used in, so a long-lived share printed 15 rooms in 362 characters inside a settings row, and the face-to-face rooms came back under "This room only" directly below it. The hint now names at most three rooms, says how many are left, and keeps the full list in its tooltip; both lists dedupe by room id, so nothing is printed twice in one card. Copy and layout only: social/access already returns the rooms. Fixes #955. --- changelog.d/955-access-rooms-hint.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 60 +++++++++++++-- plugins/social/test/social.test.js | 87 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 changelog.d/955-access-rooms-hint.fixed.md diff --git a/changelog.d/955-access-rooms-hint.fixed.md b/changelog.d/955-access-rooms-hint.fixed.md new file mode 100644 index 00000000..af44817e --- /dev/null +++ b/changelog.d/955-access-rooms-hint.fixed.md @@ -0,0 +1 @@ +**People with access** no longer prints every room a share is used in on one endless line: the hint names at most three rooms, says how many are left (`+12 more`) and keeps the full list in its tooltip, and a room the grant row already named is not repeated under **This room only** ([#955](https://github.com/ThinkFlowLab/FlyBot/issues/955)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index b14b0746..5903d44c 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; 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); **People with access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. 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; 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); **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. 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 76b31ac5..b9a8dd15 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -22,6 +22,48 @@ window.__ModuleLoader__.load({ const ALREADY_HAVE = "You already have this friend's Bot." // A friend's Bot with its owner: `Chief (SelfA1233)`, Bot first (#384). const botWithOwner = (name, owner) => (name && owner && owner !== name ? `${name} (${owner})` : name || '') + // One row per person must stay one readable line (#955): the access card used to join every room a share + // is used in, so a long-lived share printed 15 rooms / 362 characters inside a settings row. The hint names + // at most three rooms, says how many are left, and keeps every name in the tooltip. + const ROOM_HINT_LIMIT = 80 + const ROOM_HINT_NAMES = 3 + const ROOM_HINT_CLIP = 14 + const roomKey = room => String(room?.id ?? room?.name ?? '') + const clipRoom = name => (name.length > ROOM_HINT_CLIP ? `${name.slice(0, ROOM_HINT_CLIP - 1)}…` : name) + // Dedupe by room id: the same face-to-face room arrives once per grant and once in view.rooms. + const roomList = (rooms, skip) => { + const seen = new Set(skip ?? []) + const out = [] + for (const room of rooms ?? []) { + const key = roomKey(room) + if (!key || seen.has(key)) continue + seen.add(key) + const label = String(room?.name || room?.id || '').trim() + if (label) out.push({ id: key, label }) + } + return out + } + // `use` counts ("In use in 15 rooms: a, b, c +12 more"); `only` keeps its own words. + const roomHint = (kind, rooms, skip) => { + const rows = roomList(rooms, skip) + if (!rows.length) return null + const head = kind === 'use' ? `In use in ${rows.length} room${rows.length === 1 ? '' : 's'}: ` : 'This room only: ' + const build = (count, clip) => { + const pick = rows.slice(0, count).map(row => (clip ? clipRoom(row.label) : row.label)) + const rest = rows.length - pick.length + return head + pick.join(', ') + (rest > 0 ? ` +${rest} more` : '') + } + const top = Math.min(ROOM_HINT_NAMES, rows.length) + // Short lists print whole names; a long-lived share falls back to clipped names so the count still shows. + let text = build(top, false) + if (text.length > ROOM_HINT_LIMIT) { + for (let count = top; count >= 1; count -= 1) { + text = build(count, true) + if (text.length <= ROOM_HINT_LIMIT) break + } + } + return { text, title: rows.map(row => row.label).join('\n'), ids: rows.map(row => row.id) } + } const errorText = (e) => { const code = String(e?.code ?? '') const http = code.match(/^HTTP_(\d+)$/) @@ -2944,17 +2986,22 @@ window.__ModuleLoader__.load({ finally { setBusy('') } } const grants = view?.grants ?? [] + // A room the grant rows already named must not come back under "This room only" (#955). + const listedRoomIds = grants.flatMap(grant => roomList(grant.rooms).map(row => row.id)) + const roomOnlyUsage = roomHint('only', (view?.rooms ?? []).filter(room => room.origin === 'face-to-face'), listedRoomIds) return h('div', { className: 'fs fs-card', 'data-flybot': 'manage-access' }, h('div', { className: 'fs-name' }, 'People with access'), h('div', { className: 'fs-hint fs-face-note' }, ACCESS_NOTE), grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Copy an invite link above and send it to a friend.') : null, - grants.map(grant => h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, + grants.map((grant) => { + const grantUsage = roomHint('use', grant.rooms) + return h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, h('div', { className: 'fs-col' }, h('span', { className: 'fs-name' }, grant.name || grant.grantee), grant.home && grant.name && !String(grant.name).includes(grant.home) ? h('span', { className: 'fs-sub' }, grant.home) : null, - (grant.rooms ?? []).length ? h('span', { className: 'fs-hint' }, `In use: ${(grant.rooms ?? []).map(room => room.name || room.id).join(', ')}`) : null), + grantUsage ? h('span', { className: 'fs-hint', title: grantUsage.title }, grantUsage.text) : null), grant.home ? h('label', { className: 'fs-auto fs-access-auto', 'data-flybot': 'access-auto-send', title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.` }, h('input', { type: 'checkbox', checked: grant.autoSend === true, disabled: Boolean(busy), @@ -2966,10 +3013,9 @@ window.__ModuleLoader__.load({ type: 'button', className: 'fs-btn', disabled: Boolean(busy), 'data-flybot': 'revoke-grant', 'aria-label': `Revoke ${grant.name || grant.grantee}`, onClick: () => revoke(grant), - }, busy === grant.id ? 'Revoking…' : 'Revoke'))), - (view?.rooms ?? []).filter(room => room.origin === 'face-to-face').length - ? h('div', { className: 'fs-hint' }, `This room only: ${(view.rooms ?? []).filter(room => room.origin === 'face-to-face').map(room => room.name || room.id).join(', ')}`) - : null, + }, busy === grant.id ? 'Revoking…' : 'Revoke')) + }), + roomOnlyUsage ? h('div', { className: 'fs-hint', title: roomOnlyUsage.title }, roomOnlyUsage.text) : null, error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } @@ -3325,6 +3371,8 @@ window.__ModuleLoader__.load({ ChatApproval, holdReasonLine, listedFriendChat, + roomHint, + roomList, ChatApprovalDock, HeldReplyCard, approvalsForOpenChat, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 7db8cfe3..21198c2a 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -10472,3 +10472,90 @@ test('#881: a room line names no room, even when both Bots share a face room too assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'plain line', role: 'reply' })).action, 'allow') assert.equal(server.calls.filter(c => c.name === 'send_as_member').at(-1).args.room_id, 'frm_123456') }) + +// #955: "People with access" joined every room a share is used in into one line, so a long-lived share printed +// 15 rooms / 362 characters inside a settings row. The hint names at most three rooms, says how many are left, +// and the tooltip keeps every name. Dedupe is by room id, so the same face-to-face room is not printed twice in +// one card (once per grant, once under "This room only"). +const accessClientPlugin = () => { + const registered = [] + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console } + sandbox.globalThis = sandbox + sandbox.__flybot__ = { slots: { register: () => () => {} }, call: async () => [] } + runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) + const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) + plugin.apply({}) + return plugin +} + +test('#955: the access hint names three rooms and a count, inside one line', () => { + const plugin = accessClientPlugin() + const rooms = Array.from({ length: 15 }, (_, i) => ({ id: `session-r${i}`, name: `Face-to-face · SelfTest A2, SelfTest B2 · ${856400 + i}` })) + const hint = plugin.roomHint('use', rooms) + assert.ok(hint.text.length <= 80, `the hint is ${hint.text.length} characters: ${hint.text}`) + assert.match(hint.text, /^In use in 15 rooms: /) + assert.match(hint.text, /\+12 more$/, 'the count says how many are left') + assert.equal((hint.text.match(/,/g) ?? []).length, 2, 'three names at most') + assert.equal(hint.title.split('\n').length, 15, 'the tooltip carries every room') + assert.equal(hint.title.split('\n')[14], rooms[14].name) +}) + +test('#955: no room is named twice in one card, and a short list stays literal', () => { + const plugin = accessClientPlugin() + const shared = { id: 'session-shared', name: 'Face-to-face · A2, B2 · 490512' } + const other = { id: 'session-other', name: 'Face-to-face · A2, B2 · 856467' } + const listed = plugin.roomList([shared]).map(row => row.id) + const only = plugin.roomHint('only', [shared, other], listed) + assert.equal(only.text, `This room only: ${other.name}`, 'the room already named in a grant row is not repeated') + const single = plugin.roomHint('use', [other]) + assert.equal(single.text, `In use in 1 room: ${other.name}`, 'one room prints as it is, with no +0 more') + assert.equal(single.text.includes('+0 more'), false) + assert.equal(plugin.roomHint('use', []), null, 'a grant with no rooms renders no hint') + assert.equal(plugin.roomHint('use', [other, { ...other }, other]).text, single.text, 'the same room id counts once') +}) + +test('#955: the rendered access row keeps one short line and the repeat is gone', () => { + const rooms = Array.from({ length: 15 }, (_, i) => ({ id: `session-r${i}`, name: `Face-to-face · SelfTest A2, SelfTest B2 · ${856400 + i}`, origin: 'face-to-face' })) + const registered = [] + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console } + sandbox.globalThis = sandbox + sandbox.__flybot__ = { + slots: { register: () => () => {} }, + call: async (endpoint, payload) => { + if (endpoint === 'social/access') { + return { botId: payload.botId, botName: 'Writer', granted: true, grants: [{ id: 'g1', grantee: '@bob.home', name: 'Bob', home: '@bob.home', person: 'Bob', autoSend: false, rooms }], rooms } + } + return {} + }, + } + runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) + const walk = (node, all = []) => { + if (!node || typeof node !== 'object') return all + if (typeof node.type === 'function') return walk(node.type(node.props), all) + all.push(node) + for (const child of node.children ?? []) walk(child, all) + return all + } + const store = [{ botId: 'b', botName: 'Writer', granted: true, grants: [{ id: 'g1', grantee: '@bob.home', name: 'Bob', home: '@bob.home', person: 'Bob', autoSend: false, rooms }], rooms }] + let cursor = 0 + const plugin = registered[0].factory(() => ({ + createElement: h, Fragment: 'F', + useEffect(fn) { fn() }, + useState: v => { const i = cursor++; if (store[i] === undefined) store[i] = v; return [store[i], n => { store[i] = typeof n === 'function' ? n(store[i]) : n }] }, + useRef: v => ({ current: v }), useMemo: f => f(), + })) + cursor = 0 + const tree = walk(plugin.ManageAccess({ bot: { id: 'b', name: 'Writer' } })) + const hints = tree.filter(n => typeof n.children?.[0] === 'string' && /In use in/.test(n.children[0])) + assert.equal(hints.length, 1, 'one usage hint per person') + const hint = hints[0] + const line = hint.children[0] + assert.ok(line.length <= 80, `the rendered hint is ${line.length} characters: ${line}`) + assert.match(line, /^In use in 15 rooms: /) + assert.match(line, /\+12 more$/) + assert.equal((line.match(/,/g) ?? []).length, 2, 'three room names at most') + assert.equal(String(hint.props.title ?? '').split('\n').length, 15, 'the tooltip carries all 15') + const repeated = tree.filter(n => typeof n.children?.[0] === 'string' && /This room only:/.test(n.children[0])) + assert.equal(repeated.length, 0, 'a room the grant row already named is not printed again') +})