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') +})