From cc1fa8e89f87d972aa865dbcfa14cf081c09cef7 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:59:21 +0800 Subject: [PATCH] social: name the read-only Sharing change by its own issue (#2446) The comments and the test names carried #2431, the sibling workflow issue; this change is #2446. Comment text only, no behavior. --- changelog.d/2446-sharing-read-access.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 34 +++++--- plugins/social/test/social-readonly.test.js | 85 ++++++++++++++++++- 4 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 changelog.d/2446-sharing-read-access.fixed.md diff --git a/changelog.d/2446-sharing-read-access.fixed.md b/changelog.d/2446-sharing-read-access.fixed.md new file mode 100644 index 000000000..d1ee02db3 --- /dev/null +++ b/changelog.d/2446-sharing-read-access.fixed.md @@ -0,0 +1 @@ +A Bot's Sharing card in a read-only window keeps **People with access** (who has this Bot, and its Auto-send state) and drops only its writes — no Revoke, no invite, no Share with a friend ([#2446](https://github.com/ThinkFlowLab/FlyBot/issues/2446)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 0c38371ff..49a1c5f46 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -195,7 +195,7 @@ Settings → **Bot Social** lists friends and shares once the server is connecte ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): `faceWaitCode` and `shownFaceTitle` refuse a stored value that is not exactly six digits (`1234567` never reads as `123456`) and fall back to the code the owner typed, or to the creation time. -- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter ` left the room` is not also posted into the transcript. The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). A read-only window (the shell names it in `roster.readOnly`) keeps every read and drops every write (#2219): the section still polls `social/status` and still lists share requests, accept notices and past rooms, but draws no Dismiss, no Ask them to share a Bot, no Leave and no decision on a held send, offers none of the three Bot Social rows in the sidebar `+` menu, and `social/face-open` returns the stored past view (`readOnly: true`, name, status, members) instead of `upsertFace`-ing a local mirror, so reopening a past room creates no room; the room view's own head row draws the same chip-only head as a past room, with no Leave, no Auto-send switch and no invite (#2228), while a waiting room keeps its wait code and roster and drops its Leave and Enter chat; a local Bot's **Sharing** card and a local group's **Members** card draw only the window's reason, with no invite maker, no **Share with a friend**, no **People with access** and no **Create link** (#2418); a writable window behaves exactly as before. +- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter ` left the room` is not also posted into the transcript. The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). A read-only window (the shell names it in `roster.readOnly`) keeps every read and drops every write (#2219): the section still polls `social/status` and still lists share requests, accept notices and past rooms, but draws no Dismiss, no Ask them to share a Bot, no Leave and no decision on a held send, offers none of the three Bot Social rows in the sidebar `+` menu, and `social/face-open` returns the stored past view (`readOnly: true`, name, status, members) instead of `upsertFace`-ing a local mirror, so reopening a past room creates no room; the room view's own head row draws the same chip-only head as a past room, with no Leave, no Auto-send switch and no invite (#2228), while a waiting room keeps its wait code and roster and drops its Leave and Enter chat; a local Bot's **Sharing** card draws the window's reason, no invite maker and no **Share with a friend**, and keeps **People with access** readable — the list of who has this Bot, and each person's Auto-send as a state line rather than a switch, with no Revoke — because `social/access` is a read the host allows (#2219, #2446), while a local group's **Members** card keeps no read of its own and draws only the reason with no **Create link** (#2418); a writable window behaves exactly as before. - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). A number that is not empty and not exactly six digits keeps **Join** disabled and shows **Enter 6 digits** under the field (#1052). Six digits clear that line and enable Join once a Bot is picked; an empty field shows nothing. - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - **Open** on a Past room calls `social/face-open` (`plugins/social/src/social.js`): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come from `plugins/social/src/face-title.js`, with participant owners in stable handle order, so both homes show the same name (#172). diff --git a/plugins/social/client.js b/plugins/social/client.js index e52d36dfa..40dd8de37 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3285,7 +3285,7 @@ window.__ModuleLoader__.load({ peopleAccessBot = botId actions.openSettings('bots', botId) } - function ManageAccess({ bot, onGrants }) { + function ManageAccess({ bot, onGrants, readOnly = false }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') const [error, setError] = useState('') @@ -3342,6 +3342,9 @@ window.__ModuleLoader__.load({ finally { setBusy('') } } const grants = view?.grants ?? [] + // #2446: People with access is a read, so a read-only window keeps the list and its + // Auto-send state, and only the two writes (Revoke, the Auto-send switch) go. + const note = readOnly ? 'One row per Bot. Default: nobody, Chief included.' : ACCESS_NOTE // One Auto-send switch per person, however many of their Bots are rows (#995). const autoShown = new Set() // A room the grant rows already named must not come back under "This room only" (#955). @@ -3364,7 +3367,7 @@ window.__ModuleLoader__.load({ } 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), + h('div', { className: 'fs-hint fs-face-note' }, 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) => { const grantUsage = roomHint('use', grant.rooms) @@ -3383,14 +3386,20 @@ window.__ModuleLoader__.load({ identity ? h('span', { className: 'fs-sub' }, identity) : null, identity && grantUsage ? ' · ' : null, grantUsage ? h('span', { className: 'fs-hint', title: grantUsage.title }, grantUsage.text) : null) : null), - showAuto ? 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), - 'aria-label': `Auto-send ${bot.name || 'this Bot'} → ${grant.person || grant.home}`, - onChange: e => { void toggleAutoSend(grant, e.target.checked) }, - }), - 'Auto-send') : null, - confirming + showAuto ? (readOnly + ? h('span', { + className: 'fs-hint fs-access-auto', 'data-flybot': 'access-auto-state', + title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.`, + }, grant.autoSend === true ? 'Auto-send on' : 'Auto-send off') + : 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), + 'aria-label': `Auto-send ${bot.name || 'this Bot'} → ${grant.person || grant.home}`, + onChange: e => { void toggleAutoSend(grant, e.target.checked) }, + }), + 'Auto-send')) : null, + readOnly ? null + : confirming ? h('span', { className: 'fs-leave-ask fs-access-ask', 'data-flybot': 'revoke-grant-confirm' }, h('span', { className: 'fs-hint' }, revokeAsk(grant)), h('button', { @@ -3508,8 +3517,11 @@ window.__ModuleLoader__.load({ // read-only window can only have them refused, so it reads the reason instead. const locked = readOnlyReason(roster) if (locked) { + // #2446: social/access is registered `{ read: true }`, so People with access stays readable + // here; only its writes (Revoke, Auto-send) and the two invite entries go. return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, - h('div', { className: 'fs-hint', 'data-flybot': 'sharing-readonly', role: 'status' }, locked)) + h('div', { className: 'fs-hint', 'data-flybot': 'sharing-readonly', role: 'status' }, locked), + h(ManageAccess, { bot, readOnly: true })) } return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, h(InviteToBot, { bot, revocable: granted > 0 }), diff --git a/plugins/social/test/social-readonly.test.js b/plugins/social/test/social-readonly.test.js index 6fa811d66..37aaefdf5 100644 --- a/plugins/social/test/social-readonly.test.js +++ b/plugins/social/test/social-readonly.test.js @@ -289,18 +289,29 @@ const detailsTree = (roster) => { render(app.BotSocialDetails, inst) return render(app.BotSocialDetails, inst) } -const SHARING_WRITES = ['mint-invite', 'share-with-friend', 'manage-access'] +// #2446: People with access is a read card (`social/access` is `{ read: true }`), so only the +// invite and the one-click share are writes here; its Revoke and Auto-send are checked below. +const SHARING_WRITES = ['mint-invite', 'share-with-friend'] +// #2446: the write tags inside People with access, read by the same probe the other read-only tests use. +const ACCESS_WRITES = ['manage-access-revoke', 'revoke-grant', 'access-auto-send'] test('#2418: a read-only window draws no Sharing write control', async () => { fixtures() try { const tree = detailsTree(readOnlyRoster()) const tags = flybotTags(tree) - // Red first on a091ddc07d: 3 tags and the Copy invite link button. + // Red first on a091ddc07d: 3 tags and the Copy invite link button. #2446: People with access is + // a read (social/access is `{ read: true }`), so the card stays and only its writes go; the read + // half is checked below. const writes = tags.filter(tag => SHARING_WRITES.includes(tag)) assert.deepEqual(writes, [], `no Sharing write control in a read-only window, saw ${writes.join(',')}`) + const sharingWrites = ['mint-invite', 'share-with-friend', 'manage-access-revoke', 'revoke-grant', 'access-auto-send'] + assert.deepEqual(tags.filter(tag => sharingWrites.includes(tag)), [], `no write inside the Sharing card, saw ${tags.filter(tag => sharingWrites.includes(tag)).join(',')}`) const writeButtons = buttonLabels(tree).filter(label => label.startsWith('Copy invite link') || label === 'Share with a friend') assert.deepEqual(writeButtons, [], `no Sharing write button in a read-only window, saw ${writeButtons.join(',')}`) + // The read half stays: the list of who has this Bot is drawn, without a way to change it. + assert.equal(byTag(tree, 'manage-access').length, 1, 'People with access is still drawn in a read-only window') + assert.deepEqual(byTag(tree, 'revoke-grant'), [], 'no Revoke in a read-only window') // The card is not silently blank: it says why. const hint = byTag(tree, 'sharing-readonly') assert.equal(hint.length, 1, 'the Sharing card says the window is read-only') @@ -314,6 +325,8 @@ test('#2418: a writable window keeps the Sharing controls', async () => { const tree = detailsTree(writableRoster()) const tags = flybotTags(tree) for (const tag of SHARING_WRITES) assert.ok(tags.includes(tag), `a writable window keeps ${tag}`) + assert.equal(byTag(tree, 'manage-access').length, 1, 'a writable window keeps People with access') + assert.deepEqual(tags.filter(tag => ACCESS_WRITES.includes(tag)), [], 'a writable window draws the read card before its list loads, with no write tag yet') assert.deepEqual(byTag(tree, 'sharing-readonly'), [], 'a writable window shows no read-only note') } finally { delete globalThis.__flybot__ } }) @@ -343,3 +356,71 @@ test('#2418: a writable window keeps the Members-card invite control', async () assert.deepEqual(byTag(tree, 'room-invite-readonly'), []) } finally { delete globalThis.__flybot__ } }) + +// #2446 (P-557): #2418 dropped the whole Sharing card in a read-only window, but People with access +// is a read (`social/access` is registered `{ read: true }`). The list stays; only its writes go. +const ACCESS = { + grants: [ + { id: 'g1', grantee: '@eve.home', name: 'Eve', handle: '@eve.home', home: '@eve.home', person: '@eve.home', autoSend: true, rooms: [] }, + { id: 'g2', grantee: '@bob.home', name: 'Bob', handle: '@bob.home', home: '@bob.home', person: '@bob.home', autoSend: false, rooms: [] }, + ], + rooms: [], +} +const accessFixtures = () => { + const calls = [] + globalThis.__flybot__ = { + call: (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'social/access') return Promise.resolve(ACCESS) + return Promise.resolve({}) + }, + } + return calls +} +const accessTree = async (props) => { + const app = loadApp() + const inst = { hooks: {}, effects: [], props: { bot: { id: 'bot_a', name: 'Alice' }, ...props } } + render(app.ManageAccess, inst) + runEffects(inst) + await tick() + return render(app.ManageAccess, inst) +} + +test('#2446: a read-only Sharing card keeps the People with access list', async () => { + const calls = accessFixtures() + try { + const tree = await accessTree({ readOnly: true }) + const tags = flybotTags(tree) + assert.ok(tags.includes('manage-access'), 'the People with access card is still drawn') + assert.equal(tags.filter(tag => tag === 'access-grant').length, 2, 'every person with access is still listed') + const names = [] + walk(tree, node => { if (node?.props?.className === 'fs-name') names.push(String((node.children ?? []).flat().join(' ')).trim()) }) + assert.deepEqual(names.filter(name => name === 'Eve' || name === 'Bob'), ['Eve', 'Bob'], 'each row names its person') + assert.ok(tags.includes('access-meta'), 'the handle under the name is still read from social/access') + // The read half is a read: only social/access is asked, and nobody can take access away here. + assert.deepEqual(calls.filter(([endpoint]) => endpoint !== 'social/access'), [], 'a read-only card writes nothing') + assert.deepEqual(tags.filter(tag => ['manage-access-revoke', 'revoke-grant', 'access-auto-send'].includes(tag)), [], 'no Revoke and no Auto-send switch in a read-only window') + assert.deepEqual(buttonLabels(tree).filter(label => label === 'Revoke' || label === 'Keep'), [], 'no revoke step in a read-only window') + } finally { delete globalThis.__flybot__ } +}) + +test('#2446: a read-only Sharing card shows Auto-send as a state, not a switch', async () => { + accessFixtures() + try { + const tree = await accessTree({ readOnly: true }) + assert.deepEqual(byTag(tree, 'access-auto-send'), [], 'no Auto-send switch in a read-only window') + const states = byTag(tree, 'access-auto-state') + assert.equal(states.length, 2, 'one Auto-send state per person') + assert.deepEqual(states.map(node => String((node.children ?? []).flat().join(' ')).trim()), ['Auto-send on', 'Auto-send off'], 'the state says what the pair is set to') + } finally { delete globalThis.__flybot__ } +}) + +test('#2446: a writable Sharing card keeps Revoke and the Auto-send switch', async () => { + accessFixtures() + try { + const tree = await accessTree({}) + assert.equal(byTag(tree, 'revoke-grant').length, 2, 'a writable window keeps one Revoke per person') + assert.equal(byTag(tree, 'access-auto-send').length, 2, 'a writable window keeps the Auto-send switch') + assert.deepEqual(byTag(tree, 'access-auto-state'), [], 'a writable window draws no state text instead of its switch') + } finally { delete globalThis.__flybot__ } +})