diff --git a/changelog.d/2009-human-seat-rename-broadcast.fixed.md b/changelog.d/2009-human-seat-rename-broadcast.fixed.md new file mode 100644 index 000000000..e3727c7d8 --- /dev/null +++ b/changelog.d/2009-human-seat-rename-broadcast.fixed.md @@ -0,0 +1 @@ +Adding or removing a person in a group without a chosen name now tells the guests already seated the new name ([#2009](https://github.com/ThinkFlowLab/FlyBot/issues/2009)): before this a human seat moved the host's own name and Session title while the other homes kept the copy they arrived with, because only a Bot's seat change sent the one `join` a guest holds. A group with a chosen name, a mirror of another home's room and a face-to-face room still send nothing for a seat change. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index dcb5d1a8a..31870399d 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -764,6 +764,10 @@ export function install(rt) { // the room get the same name once, and the arriving seat keeps its one targeted join. if (moved && broadcast) reshareRoster(room, { except: record.handle }) } else if (record?.kind === 'bot' && !record.remote && broadcast) reshareRoster(room) + // #2009: a human seat is this home's own, so it never changes the roster a guest holds — but it does + // change the name an unnamed group is called after, and a human seat has no envelope of its own to + // carry that name. Only `moved` sends, so a chosen name still sends nothing. + else if (record?.kind === 'human' && moved && broadcast) reshareRoster(room) return room }) } @@ -822,6 +826,10 @@ export function install(rt) { await finishTicketLeave(room).catch(error => rt.warn?.('ds-bot: leaving %s was not settled: %s', room.name, error)) } else if (kind === 'bot' && !me.remote && broadcast) { reshareRoster(room) + } else if (kind === 'human' && moved && broadcast) { + // #2009: the same as an add — a human leave moves the name of an unnamed group, and the guests + // still seated have no other way to hear it. + reshareRoster(room) } // #2004: the leave moved the name, so the guests still in the room get the copy the host holds. The // seat that left is already out of `currentParticipants`; a local Bot's leave sent its roster above. diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 7ce4d234b..612cc5fc9 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -3772,3 +3772,84 @@ test('#2004: a claim that seats a Bot moves the name for the guest already in th assert.equal(guest.copies.get('@chief.bob.home'), room.name, 'the claimed seat is told the name it is seated under') assert.equal(joins.get('@chief.bob.home'), 1, 'the claimed seat is told once, not twice') }) + +// #2009: a human seat is this home's own, so it never changes the roster a guest holds — but it does +// change the name an unnamed group is called after. That name only ever left on an envelope addressed +// to the seat that changed, and a human seat is not addressable: the guests already seated kept the +// copy they arrived with while the host moved on. +function seatBob(rt) { + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2, displayName: 'Bob' } +} + +test('#2009: adding a human seat moves the name for the guest already in the room', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the guest is in the name it arrived under') + + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Bob, Chief A, Chief B, Coder', 'the human seat is in the name of an unnamed group') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already seated ends on the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one human seat costs a seated guest one join, not none and not two') +}) + +test('#2009: removing a human seat moves the name for the guest still seated', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + guest.reads() + assert.equal(room.name, 'Alice, Bob, Chief A, Chief B, Coder') + + await rt.removeRoomParticipant('war', { kind: 'human', id: 'owner-2' }, ALICE) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the human seat is gone from the name too') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest still seated ends on the name the host holds after a human leave') + assert.equal(joins.get(CODER.handle), 1, 'one human leave costs a seated guest one join, not none and not two') +}) + +test('#2009: a group with a chosen name sends its seated guests nothing for a human seat', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + let joins = guest.reads() + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a human seat sends nothing when the name did not move') + + await rt.removeRoomParticipant('war', { kind: 'human', id: 'owner-2' }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a human leave sends nothing either') + assert.equal(titles.length, 0, 'and no Session is renamed for a chosen name') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 794611587..a3d938438 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -327,7 +327,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): `followMemberNames` recomputes the name after a Bot is renamed or removed and sends that one `join` too, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. A seat changing moves it as well ([#1995](https://github.com/ThinkFlowLab/FlyBot/issues/1987)): every path that seats or unseats somebody — `addRoomParticipant`, `removeRoomParticipant`, a room link's seats claimed into the room, a seat's own leave, and the grant eviction — ends in `settleRoomName`, which recomputes an unnamed room's name through the same wide formula and hands it to the room's own Session, so a guest who joins is in the title and a guest who leaves drops out of it instead of staying in `state.json`; a group made with a guest is opened before its seats are known, so `createRoom` retitles that Session once the participants are applied. The other homes learn it too ([#2004](https://github.com/ThinkFlowLab/FlyBot/issues/2004)): `settleRoomName` answers whether the name moved, and the path that moved it sends one `join` through `reshareRoster` to the guests already seated, so their copy of the name is the same text the host holds; the seat that just changed is left out of that broadcast, because its own targeted `join` is the one it is owed, On the settings page that broadcast is once per change ([#2011](https://github.com/ThinkFlowLab/FlyBot/issues/2011)): `updateRoom` already sends the final name itself after its save, so the seats it adds or removes are taken with `{ broadcast: false }` and the seat path adds no second `join` — a guest already seated hears exactly one, the arriving seat keeps its own targeted `join` plus that settings-page one, and a name or notice the patch asks for is still told in one envelope. A room with a chosen name, a mirror of another home's room (`inbound-share`) and a face-to-face room keep the title they have. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. The name on that line is the signed-in member, else the Bot that acted through its own tool, else the room's implicit owner ([#1967](https://github.com/ThinkFlowLab/FlyBot/issues/1967)): the web UI keeps no member session, so without that step every save the local person makes would read `Someone`; only a room with no owner at all falls back to `the user`. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): `followMemberNames` recomputes the name after a Bot is renamed or removed and sends that one `join` too, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. A seat changing moves it as well ([#1995](https://github.com/ThinkFlowLab/FlyBot/issues/1987)): every path that seats or unseats somebody — `addRoomParticipant`, `removeRoomParticipant`, a room link's seats claimed into the room, a seat's own leave, and the grant eviction — ends in `settleRoomName`, which recomputes an unnamed room's name through the same wide formula and hands it to the room's own Session, so a guest who joins is in the title and a guest who leaves drops out of it instead of staying in `state.json`; a group made with a guest is opened before its seats are known, so `createRoom` retitles that Session once the participants are applied. The other homes learn it too ([#2004](https://github.com/ThinkFlowLab/FlyBot/issues/2004)): `settleRoomName` answers whether the name moved, and the path that moved it sends one `join` through `reshareRoster` to the guests already seated, so their copy of the name is the same text the host holds; the seat that just changed is left out of that broadcast, because its own targeted `join` is the one it is owed, On the settings page that broadcast is once per change ([#2011](https://github.com/ThinkFlowLab/FlyBot/issues/2011)): `updateRoom` already sends the final name itself after its save, so the seats it adds or removes are taken with `{ broadcast: false }` and the seat path adds no second `join` — a guest already seated hears exactly one, the arriving seat keeps its own targeted `join` plus that settings-page one, and a name or notice the patch asks for is still told in one envelope. A human seat is this home's own and never changes the roster a guest holds, but adding or removing one still moves an unnamed group's name ([#2009](https://github.com/ThinkFlowLab/FlyBot/issues/2009)): those two seat paths send the same one `join` through `reshareRoster`, and only when `settleRoomName` answers that the name moved, so a chosen name is still sent nothing. A room with a chosen name, a mirror of another home's room (`inbound-share`) and a face-to-face room keep the title they have. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. The name on that line is the signed-in member, else the Bot that acted through its own tool, else the room's implicit owner ([#1967](https://github.com/ThinkFlowLab/FlyBot/issues/1967)): the web UI keeps no member session, so without that step every save the local person makes would read `Someone`; only a room with no owner at all falls back to `the user`. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. - A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the calls a line makes in the envelope's `calls` (resolved where the room's mentions are resolved: the social handle, the roster name, and the title the @ menu inserts, so `@Chief (owner)` and `@Data (Friend)` name the same Bot a bare name would; a Bot with no social handle is called by that roster name, and a `not-connected:` id is never a token — #949). A fenced or quoted `@name` stays literal text. The envelope also carries the line's `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index dcb5d1a8a..31870399d 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -764,6 +764,10 @@ export function install(rt) { // the room get the same name once, and the arriving seat keeps its one targeted join. if (moved && broadcast) reshareRoster(room, { except: record.handle }) } else if (record?.kind === 'bot' && !record.remote && broadcast) reshareRoster(room) + // #2009: a human seat is this home's own, so it never changes the roster a guest holds — but it does + // change the name an unnamed group is called after, and a human seat has no envelope of its own to + // carry that name. Only `moved` sends, so a chosen name still sends nothing. + else if (record?.kind === 'human' && moved && broadcast) reshareRoster(room) return room }) } @@ -822,6 +826,10 @@ export function install(rt) { await finishTicketLeave(room).catch(error => rt.warn?.('ds-bot: leaving %s was not settled: %s', room.name, error)) } else if (kind === 'bot' && !me.remote && broadcast) { reshareRoster(room) + } else if (kind === 'human' && moved && broadcast) { + // #2009: the same as an add — a human leave moves the name of an unnamed group, and the guests + // still seated have no other way to hear it. + reshareRoster(room) } // #2004: the leave moved the name, so the guests still in the room get the copy the host holds. The // seat that left is already out of `currentParticipants`; a local Bot's leave sent its roster above. diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 7ce4d234b..612cc5fc9 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -3772,3 +3772,84 @@ test('#2004: a claim that seats a Bot moves the name for the guest already in th assert.equal(guest.copies.get('@chief.bob.home'), room.name, 'the claimed seat is told the name it is seated under') assert.equal(joins.get('@chief.bob.home'), 1, 'the claimed seat is told once, not twice') }) + +// #2009: a human seat is this home's own, so it never changes the roster a guest holds — but it does +// change the name an unnamed group is called after. That name only ever left on an envelope addressed +// to the seat that changed, and a human seat is not addressable: the guests already seated kept the +// copy they arrived with while the host moved on. +function seatBob(rt) { + rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 2, displayName: 'Bob' } +} + +test('#2009: adding a human seat moves the name for the guest already in the room', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the guest is in the name it arrived under') + + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Bob, Chief A, Chief B, Coder', 'the human seat is in the name of an unnamed group') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already seated ends on the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one human seat costs a seated guest one join, not none and not two') +}) + +test('#2009: removing a human seat moves the name for the guest still seated', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + guest.reads() + assert.equal(room.name, 'Alice, Bob, Chief A, Chief B, Coder') + + await rt.removeRoomParticipant('war', { kind: 'human', id: 'owner-2' }, ALICE) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the human seat is gone from the name too') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest still seated ends on the name the host holds after a human leave') + assert.equal(joins.get(CODER.handle), 1, 'one human leave costs a seated guest one join, not none and not two') +}) + +test('#2009: a group with a chosen name sends its seated guests nothing for a human seat', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + seatBob(rt) + const room = rt.state.rooms.war + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'human', id: 'owner-2', displayName: 'Bob' }, ALICE) + let joins = guest.reads() + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a human seat sends nothing when the name did not move') + + await rt.removeRoomParticipant('war', { kind: 'human', id: 'owner-2' }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a human leave sends nothing either') + assert.equal(titles.length, 0, 'and no Session is renamed for a chosen name') +})