From d608f2cb69186763672911ebb29554a8253b456b Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:59:18 +0800 Subject: [PATCH] social: a read-only window draws no Sharing or members invite write (#2418) The Sharing card on a local Bot and the Members card of a local group all write: create-invite, share-with-friend, revoke-grant, room-invite-mint. A read-only window can only have them refused, so both cards read the reason instead and follow the rule #2219 and #2228 already set for the rest of the plugin. --- .../2418-social-invite-readonly.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 20 +++++- plugins/social/test/social-readonly.test.js | 63 +++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2418-social-invite-readonly.fixed.md diff --git a/changelog.d/2418-social-invite-readonly.fixed.md b/changelog.d/2418-social-invite-readonly.fixed.md new file mode 100644 index 000000000..0a8eb6ba1 --- /dev/null +++ b/changelog.d/2418-social-invite-readonly.fixed.md @@ -0,0 +1 @@ +A read-only window no longer draws the write controls on a local Bot's Sharing card or on a local group's Members card: making an invite link, sharing a Bot with a friend, People with access, Create link and Withdraw are all writes that the host refuses there, so each card says why the window cannot write instead of offering a button that can only fail ([#2418](https://github.com/ThinkFlowLab/FlyBot/issues/2418)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index e22d0072d..0c38371ff 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 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 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. - 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 8034831f0..e52d36dfa 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1163,7 +1163,7 @@ window.__ModuleLoader__.load({ // #876: Invite by link in a local group's Members card. The host makes the link (Bot Social records // it, so a stranger can join); this row mints, shows the QR and the link, counts down and withdraws. // Several people may each get a link (#878): the newest is shown, and a joined one stops showing. - function LocalInviteRow({ room } = {}) { + function LocalInviteRow({ room, roster } = {}) { useStyles() const roomId = room?.id const [invite, setInvite] = useState(null) @@ -1202,6 +1202,13 @@ window.__ModuleLoader__.load({ // Only a link an older Bot Social still dates counts down. useEffect(() => (invite?.expiresAt ? everySecond(() => setTick(n => n + 1)) : undefined), [invite?.expiresAt]) if (!roomId) return null + // #2418: every control this row draws writes (mint, revoke, revoke-all, seat), and a read-only + // window can only have them refused, so it reads the reason instead. + const locked = readOnlyReason(roster) + if (locked) { + return h('div', { className: 'fs-invite', 'data-flybot': 'room-invite' }, + h('div', { className: 'fs-hint', 'data-flybot': 'room-invite-readonly', role: 'status' }, locked)) + } const run = async (fn) => { setBusy(true) setNote(null) @@ -3490,13 +3497,20 @@ window.__ModuleLoader__.load({ return h('div', { 'data-flybot': 'bot-social-details' }, h(FriendChatSettings, { bot, actions, roster })) } - return h(OwnBotSharing, { bot }) + return h(OwnBotSharing, { bot, roster }) } // The host draws the Sharing title (#406). People with access tells the invite hint whether // anyone has this Bot yet, so the hint mentions Revoke only then (#499). - function OwnBotSharing({ bot } = {}) { + function OwnBotSharing({ bot, roster } = {}) { const [granted, setGranted] = useState(0) + // #2418: all three panels below write (create-invite, share-with-friend, revoke-grant), and a + // read-only window can only have them refused, so it reads the reason instead. + const locked = readOnlyReason(roster) + if (locked) { + return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, + h('div', { className: 'fs-hint', 'data-flybot': 'sharing-readonly', role: 'status' }, locked)) + } return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, h(InviteToBot, { bot, revocable: granted > 0 }), h(ShareWithFriend, { bot }), diff --git a/plugins/social/test/social-readonly.test.js b/plugins/social/test/social-readonly.test.js index 91a890217..6fa811d66 100644 --- a/plugins/social/test/social-readonly.test.js +++ b/plugins/social/test/social-readonly.test.js @@ -280,3 +280,66 @@ test('#2228: a read-only waiting room keeps its code, drops Leave and Enter chat assert.ok(tags.includes('face-wait-roster'), 'who has joined is still drawn') } finally { delete globalThis.__flybot__ } }) + +// #2418: a Bot's own settings → Sharing. A read-only window drew the invite mint, the one-click share +// and People with access (Revoke); the host refuses every one of them with `bot/read-only`. +const detailsTree = (roster) => { + const app = loadApp() + const inst = { hooks: {}, effects: [], props: { bot: { id: 'bot_a', name: 'Alice' }, actions: {}, roster } } + render(app.BotSocialDetails, inst) + return render(app.BotSocialDetails, inst) +} +const SHARING_WRITES = ['mint-invite', 'share-with-friend', 'manage-access'] + +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. + 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 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 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') + assert.match(String((hint[0].children ?? []).flat().join(' ')), /read-only/, 'the sentence names the reason') + } finally { delete globalThis.__flybot__ } +}) + +test('#2418: a writable window keeps the Sharing controls', async () => { + fixtures() + try { + 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.deepEqual(byTag(tree, 'sharing-readonly'), [], 'a writable window shows no read-only note') + } finally { delete globalThis.__flybot__ } +}) + +// #2418: a local group's Members card (flybot.room.members) — Invite by link mints a real join link. +const membersTree = (roster) => { + const app = loadApp() + const inst = { hooks: {}, effects: [], props: { room: { id: 'r1', members: [] }, roster, actions: {}, call: async () => ({}), host: async () => ({}) } } + return render(app.LocalInviteRow, inst) +} + +test('#2418: a read-only window draws no Members-card invite control', async () => { + fixtures() + try { + const tree = membersTree(readOnlyRoster()) + assert.deepEqual(byTag(tree, 'room-invite-mint'), [], 'no Create link in a read-only window') + assert.deepEqual(buttonLabels(tree).filter(label => label === 'Create link' || label === 'New link'), []) + assert.equal(byTag(tree, 'room-invite-readonly').length, 1, 'the Members card says the window is read-only') + } finally { delete globalThis.__flybot__ } +}) + +test('#2418: a writable window keeps the Members-card invite control', async () => { + fixtures() + try { + const tree = membersTree(writableRoster()) + assert.equal(byTag(tree, 'room-invite-mint').length, 1, 'a writable window keeps Create link') + assert.deepEqual(byTag(tree, 'room-invite-readonly'), []) + } finally { delete globalThis.__flybot__ } +})