Skip to content
Merged
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/2418-social-invite-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name> left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter `<Name> 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 `<Name> left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter `<Name> 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).
Expand Down
20 changes: 17 additions & 3 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }),
Expand Down
63 changes: 63 additions & 0 deletions plugins/social/test/social-readonly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__ }
})
Loading