Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/955-access-rooms-hint.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
60 changes: 54 additions & 6 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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+)$/)
Expand Down Expand Up @@ -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),
Expand All @@ -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)
}

Expand Down Expand Up @@ -3325,6 +3371,8 @@ window.__ModuleLoader__.load({
ChatApproval,
holdReasonLine,
listedFriendChat,
roomHint,
roomList,
ChatApprovalDock,
HeldReplyCard,
approvalsForOpenChat,
Expand Down
87 changes: 87 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Loading