From e37e3ac81da81cb53c5344da4cad5a6c1665226f Mon Sep 17 00:00:00 2001 From: Danyang Chen <15378933+Birfy@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:08:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(face-rooms):=20the=20@=20picker=20lists?= =?UTF-8?q?=20each=20Bot=20in=20the=20room=20once,=20named=20Person=20?= =?UTF-8?q?=C2=B7=20Bot=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/305-face-at-picker.fixed.md | 1 + core/client.js | 47 +++++++++++--- core/src/client/at-menu.js | 59 ++++++++++++----- core/src/client/i18n.js | 1 + core/test/at-menu.test.js | 41 ++++++++++++ docs/subsystems/core-client.md | 1 + patches/ds-bot/0001-core-fixes.patch | 5 +- patches/ds-bot/0003-flybot-api-client.patch | 70 ++++++++++++++++----- patches/ds-bot/0008-client-bundle.patch | 66 ++++++++++++++----- patches/ds-bot/0012-room-membership.patch | 45 +++++++++++++ 10 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 changelog.d/305-face-at-picker.fixed.md diff --git a/changelog.d/305-face-at-picker.fixed.md b/changelog.d/305-face-at-picker.fixed.md new file mode 100644 index 000000000..932976785 --- /dev/null +++ b/changelog.d/305-face-at-picker.fixed.md @@ -0,0 +1 @@ +In a Face-to-face room the composer's `@` lists each Bot in the room once, named like the Members panel (`SelfA · Chief`), with no friend's Bot from outside the room, no separate Friends' Bots section and no wrong "1 members" count ([#305](https://github.com/ThinkFlowLab/FlyBot/issues/305)). diff --git a/core/client.js b/core/client.js index 05969e610..0a7074c45 100644 --- a/core/client.js +++ b/core/client.js @@ -258,6 +258,7 @@ var ZH = { "Admin leads": "管理员主持", "Plain messages go to the admin, who calls others with @Name.": "普通消息先给管理员,由管理员用 @名字 点名其他成员。", Everyone: "所有人", + "Every Bot in this room": "房间里的所有 Bot", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", "{name} (you)": "{name}(你)", @@ -7191,20 +7192,44 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; + const face = isFaceRoom(here.room); + const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); const bots = here.room ? [ - ...here.room.members.map((id) => roster.byId[id]).filter(Boolean), - ...(here.room.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), - ...(here.room.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, handle: p.handle })) + ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ""), bot.name, true) } : bot), + ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), + // A remote seat already listed under its handle (a duplicate participant record) is offered once. + ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ + id: p.handle || p.id, + name: remoteBotTitle(p), + kind: "bot", + remote: p, + handle: p.handle, + ...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot"), false) } : {} + })) ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); - const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); + const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); const everyone = t("Everyone"); if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { rows.push({ room: here.room, text: `@${everyone} ` }); } - rows.push(...remoteMentionRows(roster, query)); + if (!face) rows.push(...remoteMentionRows(roster, query)); return rows; } +var isFaceRoom = (room2) => room2?.origin === "face-to-face"; +var uniqueBy = (list, key) => { + const seen = /* @__PURE__ */ new Set(); + return list.filter((item) => { + const k = key(item); + if (!k || seen.has(k)) return !k; + seen.add(k); + return true; + }); +}; +var faceLabel = (person, bot, mine) => { + if (!person) return bot; + return `${mine ? t("{name} (you)", { name: person }) : person} · ${bot}`; +}; function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; @@ -7234,17 +7259,19 @@ function atSources({ roster, sessions, launched, iconFor }) { showGroupTitle: false, async candidates(session, { query }) { const snapshot = roster.getSnapshot(); + const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ - name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"), - ...row.bot?.role ? { description: row.bot.role } : {}, + name: row.bot ? row.bot.label ?? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"), + ...row.bot?.role && !face ? { description: row.bot.role } : {}, ...row.remote ? { description: remoteBotTitle(row.remote) } : {}, - ...row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}, - ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, + ...row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}, + // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). + ...row.room ? { description: face ? t("Every Bot in this room") : t("{count} members", { count: row.room.members.length }) } : {}, icon: iconFor(row), section: t("Bots"), value: row.text })); - if (mentionRows(snapshot, session.sessionId, "") === null) return team; + if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ name: row.name, ...row.description ? { description: row.description } : {}, diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 50129fbec..89c56914f 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -4,7 +4,7 @@ // Both are sources of the shell's trigger menu; the shell's own file and session // rows stay beside them, because a source cannot be hidden for one Session. import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -import { remoteBotTitle, listedChatBots } from '../shared/remote-label.js' +import { cleanPeerName, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' @@ -27,26 +27,54 @@ const conversationOf = (roster, sessionId) => { export function mentionRows(roster, sessionId, query = '') { const here = conversationOf(roster, sessionId) if (here === null) return null + const face = isFaceRoom(here.room) + const live = (here.room?.participants ?? []).filter(p => p.leftSeq === undefined) const bots = here.room ? [ - ...here.room.members.map(id => roster.byId[id]).filter(Boolean), - ...(here.room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) + ...here.room.members.map(id => roster.byId[id]).filter(Boolean) + .map(bot => (face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ''), bot.name, true) } : bot)), + ...live.filter(p => p.kind === 'human') .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })), - ...(here.room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) - .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle })), + // A remote seat already listed under its handle (a duplicate participant record) is offered once. + ...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id) + .map(p => ({ + id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle, + ...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot'), false) } : {}), + })), ] : listedChatBots(roster).filter(bot => bot.id !== here.self.id) const needle = query.trim().toLowerCase() - const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` })) + const rows = bots + .filter(bot => bot.name.toLowerCase().includes(needle) || String(bot.label ?? '').toLowerCase().includes(needle)) + .map(bot => ({ bot, text: `@${bot.name} ` })) const everyone = t('Everyone') if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some(word => word.toLowerCase().startsWith(needle))) { rows.push({ room: here.room, text: `@${everyone} ` }) } - // Friends' remote Bots are members of every chat, marked remote. - rows.push(...remoteMentionRows(roster, query)) + // Friends' remote Bots are members of every chat, marked remote. Not of a face-to-face + // room: only the Bots in that room can be reached there, each listed once above (#305). + if (!face) rows.push(...remoteMentionRows(roster, query)) return rows } +const isFaceRoom = room => room?.origin === 'face-to-face' +const uniqueBy = (list, key) => { + const seen = new Set() + return list.filter((item) => { + const k = key(item) + if (!k || seen.has(k)) return !k + seen.add(k) + return true + }) +} +// In a face-to-face room a Bot is named as the Members panel names it: "Ann · Chief", +// "Me (you) · Chief" (#229, #305). Only the label changes; the inserted "@Name" stays the +// one the room routes on. +const faceLabel = (person, bot, mine) => { + if (!person) return bot + return `${mine ? t('{name} (you)', { name: person }) : person} · ${bot}` +} + // The chats a message here can reference: every other Bot's chat, then the latest // conversations outside the team. Group chats stay out: a group's Session holds only // the user's messages, so a snapshot of it would miss every Bot's reply. @@ -89,18 +117,21 @@ export function atSources({ roster, sessions, launched, iconFor }) { showGroupTitle: false, async candidates(session, { query }) { const snapshot = roster.getSnapshot() + const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]) const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({ - name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'), - ...(row.bot?.role ? { description: row.bot.role } : {}), + name: row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'), + ...(row.bot?.role && !face ? { description: row.bot.role } : {}), ...(row.remote ? { description: remoteBotTitle(row.remote) } : {}), - ...(row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}), - ...(row.room ? { description: t('{count} members', { count: row.room.members.length }) } : {}), + ...(row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}), + // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). + ...(row.room ? { description: face ? t('Every Bot in this room') : t('{count} members', { count: row.room.members.length }) } : {}), icon: iconFor(row), section: t('Bots'), value: row.text, })) - // FlyBot plugins' rows (remote Bots...), only where the team's menu shows. - if (mentionRows(snapshot, session.sessionId, '') === null) return team + // FlyBot plugins' rows (remote Bots...), only where the team's menu shows, and not in a + // face-to-face room, whose Bots are all listed above (no separate Friends' Bots, #305). + if (mentionRows(snapshot, session.sessionId, '') === null || face) return team const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map(row => ({ name: row.name, ...(row.description ? { description: row.description } : {}), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 307e8e881..525e65ee8 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -225,6 +225,7 @@ const ZH = { 'Admin leads': '管理员主持', 'Plain messages go to the admin, who calls others with @Name.': '普通消息先给管理员,由管理员用 @名字 点名其他成员。', Everyone: '所有人', + 'Every Bot in this room': '房间里的所有 Bot', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', '{name} (you)': '{name}(你)', diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index 5593b3d42..e52662d67 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -95,3 +95,44 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' }) assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), []) }) + +test('#305: a face room offers each of its Bots once, named Person · Bot, and no friend\'s Bot from outside', async () => { + const face = indexRoster({ + self: { memberId: 'm1', displayName: 'SelfB' }, + bots: [{ id: 'chief', name: 'Chief', role: 'Chief of Staff', sessionId: 'chief' }], + rooms: [{ + id: 'face', name: 'Face-to-face · SelfA, SelfB · 482913', origin: 'face-to-face', members: ['chief'], + participants: [ + { kind: 'bot', id: 'chief', handle: '@chief.selfb.home', displayName: 'Chief' }, + { kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' }, + // The same seat twice (a replayed join) is still one row. + { kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' }, + { kind: 'bot', remote: true, id: '@coder.gone.home', handle: '@coder.gone.home', displayName: 'Coder', ownerName: 'Gone', leftSeq: 4 }, + ], + }], + remoteBots: [{ handle: '@chief.selfa.home', name: 'Chief', home: '@selfa.home', owner: 'SelfA' }, { handle: '@helper.other.home', name: 'Helper', home: '@other.home' }], + }) + const rows = mentionRows(face, 'face') + assert.equal(rows.filter(row => row.remote).length, 0, 'no published-friend rows in a face room') + assert.deepEqual(rows.map(row => row.bot?.label ?? 'everyone'), ['SelfB (you) · Chief', 'SelfA · Chief', 'everyone']) + // What is inserted is still the name the room routes on, never a handle. + assert.deepEqual(rows.map(row => row.text), ['@Chief ', "@SelfA's Chief ", '@Everyone ']) + assert.ok(rows.every(row => !/@[\w-]+\.[\w-]+\.home/.test(row.text))) + assert.deepEqual(mentionRows(face, 'face', 'selfa').map(row => row.text), ["@SelfA's Chief "]) + + const [bots] = atSources({ + roster: { getSnapshot: () => face }, + sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, + launched: () => false, + iconFor: () => 'bot', + }) + const listed = await bots.candidates({ sessionId: 'face' }, { query: '' }) + assert.deepEqual(listed.map(row => [row.name, row.description]), [ + ['SelfB (you) · Chief', undefined], + ['SelfA · Chief', undefined], + ['Everyone', 'Every Bot in this room'], + ]) + // Outside a face room the published friends' Bots still join the menu. + const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] }) + assert.equal(mentionRows(group, 'face').filter(row => row.remote).length, 2) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2166c6fee..511f51a27 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -105,6 +105,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). - A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `group-settings.js`, used by the overview and group settings): one row per live Bot, ` · `, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229). +- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`SelfA · Chief`, `SelfB (you) · Chief`), plus **Everyone** (`Every Bot in this room`; the room's local member list holds only this home's Bots, so it shows no count). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). ### Hidden DSH tool-removal notices diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c6ea07f92..a7b3bcac9 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -345,8 +345,11 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Header 'Message {name}': '给 {name} 发消息', -@@ -154,6 +154,7 @@ const ZH = { +@@ -152,8 +152,10 @@ const ZH = { + 'Admin leads': '管理员主持', + 'Plain messages go to the admin, who calls others with @Name.': '普通消息先给管理员,由管理员用 @名字 点名其他成员。', Everyone: '所有人', ++ 'Every Bot in this room': '房间里的所有 Bot', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', + '{name} (you)': '{name}(你)', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 6ee1fa3fe..ee556a477 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -8,54 +8,92 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js // Both are sources of the shell's trigger menu; the shell's own file and session // rows stay beside them, because a source cannot be hidden for one Session. +import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -+import { remoteBotTitle, listedChatBots } from '../shared/remote-label.js' ++import { cleanPeerName, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' -@@ -26,14 +26,22 @@ export function mentionRows(roster, sessionId, query = '') { +@@ -25,18 +25,54 @@ const conversationOf = (roster, sessionId) => { + export function mentionRows(roster, sessionId, query = '') { const here = conversationOf(roster, sessionId) if (here === null) return null ++ const face = isFaceRoom(here.room) ++ const live = (here.room?.participants ?? []).filter(p => p.leftSeq === undefined) const bots = here.room - ? here.room.members.map(id => roster.byId[id]).filter(Boolean) - : roster.bots.filter(bot => bot.id !== here.self.id) + ? [ -+ ...here.room.members.map(id => roster.byId[id]).filter(Boolean), -+ ...(here.room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) ++ ...here.room.members.map(id => roster.byId[id]).filter(Boolean) ++ .map(bot => (face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ''), bot.name, true) } : bot)), ++ ...live.filter(p => p.kind === 'human') + .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })), -+ ...(here.room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) -+ .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle })), ++ // A remote seat already listed under its handle (a duplicate participant record) is offered once. ++ ...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id) ++ .map(p => ({ ++ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle, ++ ...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot'), false) } : {}), ++ })), + ] + : listedChatBots(roster).filter(bot => bot.id !== here.self.id) const needle = query.trim().toLowerCase() - const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` })) +- const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` })) ++ const rows = bots ++ .filter(bot => bot.name.toLowerCase().includes(needle) || String(bot.label ?? '').toLowerCase().includes(needle)) ++ .map(bot => ({ bot, text: `@${bot.name} ` })) const everyone = t('Everyone') if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some(word => word.toLowerCase().startsWith(needle))) { rows.push({ room: here.room, text: `@${everyone} ` }) } -+ // Friends' remote Bots are members of every chat, marked remote. -+ rows.push(...remoteMentionRows(roster, query)) ++ // Friends' remote Bots are members of every chat, marked remote. Not of a face-to-face ++ // room: only the Bots in that room can be reached there, each listed once above (#305). ++ if (!face) rows.push(...remoteMentionRows(roster, query)) return rows } -@@ -78,14 +78,27 @@ export function atSources({ roster, sessions, launched, iconFor }) { ++const isFaceRoom = room => room?.origin === 'face-to-face' ++const uniqueBy = (list, key) => { ++ const seen = new Set() ++ return list.filter((item) => { ++ const k = key(item) ++ if (!k || seen.has(k)) return !k ++ seen.add(k) ++ return true ++ }) ++} ++// In a face-to-face room a Bot is named as the Members panel names it: "Ann · Chief", ++// "Me (you) · Chief" (#229, #305). Only the label changes; the inserted "@Name" stays the ++// one the room routes on. ++const faceLabel = (person, bot, mine) => { ++ if (!person) return bot ++ return `${mine ? t('{name} (you)', { name: person }) : person} · ${bot}` ++} ++ + // The chats a message here can reference: every other Bot's chat, then the latest + // conversations outside the team. Group chats stay out: a group's Session holds only + // the user's messages, so a snapshot of it would miss every Bot's reply. +@@ -78,14 +78,30 @@ export function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { - return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map(row => ({ - name: row.bot ? row.bot.name : t('Everyone'), +- ...(row.bot?.role ? { description: row.bot.role } : {}), +- ...(row.room ? { description: t('{count} members', { count: row.room.members.length }) } : {}), + const snapshot = roster.getSnapshot() ++ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]) + const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({ -+ name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'), - ...(row.bot?.role ? { description: row.bot.role } : {}), ++ name: row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'), ++ ...(row.bot?.role && !face ? { description: row.bot.role } : {}), + ...(row.remote ? { description: remoteBotTitle(row.remote) } : {}), -+ ...(row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}), - ...(row.room ? { description: t('{count} members', { count: row.room.members.length }) } : {}), ++ ...(row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}), ++ // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). ++ ...(row.room ? { description: face ? t('Every Bot in this room') : t('{count} members', { count: row.room.members.length }) } : {}), icon: iconFor(row), section: t('Bots'), value: row.text, })) -+ // FlyBot plugins' rows (remote Bots...), only where the team's menu shows. -+ if (mentionRows(snapshot, session.sessionId, '') === null) return team ++ // FlyBot plugins' rows (remote Bots...), only where the team's menu shows, and not in a ++ // face-to-face room, whose Bots are all listed above (no separate Friends' Bots, #305). ++ if (mentionRows(snapshot, session.sessionId, '') === null || face) return team + const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map(row => ({ + name: row.name, + ...(row.description ? { description: row.description } : {}), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 66e2266ee..a3f02825b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -119,8 +119,11 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -187,6 +187,7 @@ var ZH = { +@@ -185,8 +185,10 @@ var ZH = { + "Admin leads": "管理员主持", + "Plain messages go to the admin, who calls others with @Name.": "普通消息先给管理员,由管理员用 @名字 点名其他成员。", Everyone: "所有人", ++ "Every Bot in this room": "房间里的所有 Bot", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", + "{name} (you)": "{name}(你)", @@ -3335,8 +3338,7 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { @@ -3349,7 +3351,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -4747,44 +4750,75 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,13 +5922,18 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +5922,44 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; - const bots = here.room ? here.room.members.map((id) => roster.byId[id]).filter(Boolean) : roster.bots.filter((bot) => bot.id !== here.self.id); ++ const face = isFaceRoom(here.room); ++ const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); + const bots = here.room ? [ -+ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean), -+ ...(here.room.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), -+ ...(here.room.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, handle: p.handle })) ++ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ""), bot.name, true) } : bot), ++ ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), ++ // A remote seat already listed under its handle (a duplicate participant record) is offered once. ++ ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ ++ id: p.handle || p.id, ++ name: remoteBotTitle(p), ++ kind: "bot", ++ remote: p, ++ handle: p.handle, ++ ...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot"), false) } : {} ++ })) + ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); - const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); +- const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); ++ const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); const everyone = t("Everyone"); if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { rows.push({ room: here.room, text: `@${everyone} ` }); } -+ rows.push(...remoteMentionRows(roster, query)); ++ if (!face) rows.push(...remoteMentionRows(roster, query)); return rows; } ++var isFaceRoom = (room2) => room2?.origin === "face-to-face"; ++var uniqueBy = (list, key) => { ++ const seen = /* @__PURE__ */ new Set(); ++ return list.filter((item) => { ++ const k = key(item); ++ if (!k || seen.has(k)) return !k; ++ seen.add(k); ++ return true; ++ }); ++}; ++var faceLabel = (person, bot, mine) => { ++ if (!person) return bot; ++ return `${mine ? t("{name} (you)", { name: person }) : person} · ${bot}`; ++}; function chatRows(roster, list, sessionId) { -@@ -5959,14 +5959,26 @@ function atSources({ roster, sessions, launched, iconFor }) { + const here = conversationOf(roster, sessionId); + if (here === null) return []; +@@ -5959,14 +5959,28 @@ function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { - return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map((row) => ({ - name: row.bot ? row.bot.name : t("Everyone"), +- ...row.bot?.role ? { description: row.bot.role } : {}, +- ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, + const snapshot = roster.getSnapshot(); ++ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); + const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ -+ name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"), - ...row.bot?.role ? { description: row.bot.role } : {}, ++ name: row.bot ? row.bot.label ?? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"), ++ ...row.bot?.role && !face ? { description: row.bot.role } : {}, + ...row.remote ? { description: remoteBotTitle(row.remote) } : {}, -+ ...row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}, - ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, ++ ...row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}, ++ // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). ++ ...row.room ? { description: face ? t("Every Bot in this room") : t("{count} members", { count: row.room.members.length }) } : {}, icon: iconFor(row), section: t("Bots"), value: row.text })); -+ if (mentionRows(snapshot, session.sessionId, "") === null) return team; ++ if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; + const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ + name: row.name, + ...row.description ? { description: row.description } : {}, diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 2b188fafd..bcba8271c 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1095,3 +1095,48 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js test('"@" offers a group\'s members and everyone, or every other Bot in a Bot\'s chat', () => { assert.deepEqual(names(mentionRows(roster, 'room')), ['Chief', 'King', 'everyone']) assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer']) +@@ -76,3 +76,44 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session + assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' }) + assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), []) + }) ++ ++test('#305: a face room offers each of its Bots once, named Person · Bot, and no friend\'s Bot from outside', async () => { ++ const face = indexRoster({ ++ self: { memberId: 'm1', displayName: 'SelfB' }, ++ bots: [{ id: 'chief', name: 'Chief', role: 'Chief of Staff', sessionId: 'chief' }], ++ rooms: [{ ++ id: 'face', name: 'Face-to-face · SelfA, SelfB · 482913', origin: 'face-to-face', members: ['chief'], ++ participants: [ ++ { kind: 'bot', id: 'chief', handle: '@chief.selfb.home', displayName: 'Chief' }, ++ { kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' }, ++ // The same seat twice (a replayed join) is still one row. ++ { kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' }, ++ { kind: 'bot', remote: true, id: '@coder.gone.home', handle: '@coder.gone.home', displayName: 'Coder', ownerName: 'Gone', leftSeq: 4 }, ++ ], ++ }], ++ remoteBots: [{ handle: '@chief.selfa.home', name: 'Chief', home: '@selfa.home', owner: 'SelfA' }, { handle: '@helper.other.home', name: 'Helper', home: '@other.home' }], ++ }) ++ const rows = mentionRows(face, 'face') ++ assert.equal(rows.filter(row => row.remote).length, 0, 'no published-friend rows in a face room') ++ assert.deepEqual(rows.map(row => row.bot?.label ?? 'everyone'), ['SelfB (you) · Chief', 'SelfA · Chief', 'everyone']) ++ // What is inserted is still the name the room routes on, never a handle. ++ assert.deepEqual(rows.map(row => row.text), ['@Chief ', "@SelfA's Chief ", '@Everyone ']) ++ assert.ok(rows.every(row => !/@[\w-]+\.[\w-]+\.home/.test(row.text))) ++ assert.deepEqual(mentionRows(face, 'face', 'selfa').map(row => row.text), ["@SelfA's Chief "]) ++ ++ const [bots] = atSources({ ++ roster: { getSnapshot: () => face }, ++ sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, ++ launched: () => false, ++ iconFor: () => 'bot', ++ }) ++ const listed = await bots.candidates({ sessionId: 'face' }, { query: '' }) ++ assert.deepEqual(listed.map(row => [row.name, row.description]), [ ++ ['SelfB (you) · Chief', undefined], ++ ['SelfA · Chief', undefined], ++ ['Everyone', 'Every Bot in this room'], ++ ]) ++ // Outside a face room the published friends' Bots still join the menu. ++ const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] }) ++ assert.equal(mentionRows(group, 'face').filter(row => row.remote).length, 2) ++}) From 7a5c44b495a4e11ec2746a6674345d82bac97794 Mon Sep 17 00:00:00 2001 From: Danyang Chen <15378933+Birfy@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:20:21 +0800 Subject: [PATCH 2/2] fix(face-rooms): the @ label reads my seat's owner name first, as Members does (#305) --- core/client.js | 6 +++- core/src/client/at-menu.js | 7 ++++- core/test/at-menu.test.js | 3 ++ patches/ds-bot/0003-flybot-api-client.patch | 9 ++++-- patches/ds-bot/0008-client-bundle.patch | 33 +++++++++++++-------- patches/ds-bot/0012-room-membership.patch | 5 +++- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/core/client.js b/core/client.js index 0a7074c45..350853745 100644 --- a/core/client.js +++ b/core/client.js @@ -7195,7 +7195,7 @@ function mentionRows(roster, sessionId, query = "") { const face = isFaceRoom(here.room); const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); const bots = here.room ? [ - ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ""), bot.name, true) } : bot), + ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot), ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), // A remote seat already listed under its handle (a duplicate participant record) is offered once. ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ @@ -7217,6 +7217,10 @@ function mentionRows(roster, sessionId, query = "") { return rows; } var isFaceRoom = (room2) => room2?.origin === "face-to-face"; +var myOwnerName = (roster, live, bot) => { + const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); + return cleanPeerName(seat?.ownerName || roster.self?.displayName, ""); +}; var uniqueBy = (list, key) => { const seen = /* @__PURE__ */ new Set(); return list.filter((item) => { diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 89c56914f..c4ef2c941 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -32,7 +32,7 @@ export function mentionRows(roster, sessionId, query = '') { const bots = here.room ? [ ...here.room.members.map(id => roster.byId[id]).filter(Boolean) - .map(bot => (face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ''), bot.name, true) } : bot)), + .map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot)), ...live.filter(p => p.kind === 'human') .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })), // A remote seat already listed under its handle (a duplicate participant record) is offered once. @@ -58,6 +58,11 @@ export function mentionRows(roster, sessionId, query = '') { } const isFaceRoom = room => room?.origin === 'face-to-face' +// As `faceSeats` names it: the owner name Bot Social stamped on my Bot's seat, else my own name. +const myOwnerName = (roster, live, bot) => { + const seat = live.find(p => !p.remote && p.kind !== 'human' && (p.id === bot.id || (bot.handle && p.handle === bot.handle))) + return cleanPeerName(seat?.ownerName || roster.self?.displayName, '') +} const uniqueBy = (list, key) => { const seen = new Set() return list.filter((item) => { diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index e52662d67..aaa3978cf 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -132,6 +132,9 @@ test('#305: a face room offers each of its Bots once, named Person · Bot, and n ['SelfA · Chief', undefined], ['Everyone', 'Every Bot in this room'], ]) + // Without a member-session actor (roster.self is null) the seat's own owner name is used, as Members does. + const legacy = indexRoster({ ...face, self: null, rooms: [{ ...face.rooms[0], participants: [{ ...face.rooms[0].participants[0], ownerName: 'SelfB' }, ...face.rooms[0].participants.slice(1)] }] }) + assert.equal(mentionRows(legacy, 'face')[0].bot.label, 'SelfB (you) · Chief') // Outside a face room the published friends' Bots still join the menu. const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] }) assert.equal(mentionRows(group, 'face').filter(row => row.remote).length, 2) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index ee556a477..4cdd240a9 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -12,7 +12,7 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' -@@ -25,18 +25,54 @@ const conversationOf = (roster, sessionId) => { +@@ -25,18 +25,59 @@ const conversationOf = (roster, sessionId) => { export function mentionRows(roster, sessionId, query = '') { const here = conversationOf(roster, sessionId) if (here === null) return null @@ -23,7 +23,7 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js - : roster.bots.filter(bot => bot.id !== here.self.id) + ? [ + ...here.room.members.map(id => roster.byId[id]).filter(Boolean) -+ .map(bot => (face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ''), bot.name, true) } : bot)), ++ .map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot)), + ...live.filter(p => p.kind === 'human') + .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })), + // A remote seat already listed under its handle (a duplicate participant record) is offered once. @@ -50,6 +50,11 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js } +const isFaceRoom = room => room?.origin === 'face-to-face' ++// As `faceSeats` names it: the owner name Bot Social stamped on my Bot's seat, else my own name. ++const myOwnerName = (roster, live, bot) => { ++ const seat = live.find(p => !p.remote && p.kind !== 'human' && (p.id === bot.id || (bot.handle && p.handle === bot.handle))) ++ return cleanPeerName(seat?.ownerName || roster.self?.displayName, '') ++} +const uniqueBy = (list, key) => { + const seen = new Set() + return list.filter((item) => { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a3f02825b..c1b142fb9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -678,10 +678,15 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,482 @@ function maskOf(shape) { - } - var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); - +@@ -1238,9 +1238,485 @@ function maskOf(shape) { + mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size); + maskCache.set(shape, mask); + } +- return mask; ++ return mask; ++} ++var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); ++ +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1131,7 +1136,8 @@ diff --git a/client.js b/client.js + }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) + ) : null + ); -+} + } +-var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +var FLYBOT_CSS = ` +.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} @@ -1157,10 +1163,9 @@ diff --git a/client.js b/client.js +.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } +.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } +`; -+ + // src/client/sources.js function createSource(initial) { - let value = initial; @@ -1262,7 +1262,7 @@ function createSource(initial) { } }; @@ -3338,7 +3343,8 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { @@ -3351,8 +3357,7 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -4750,7 +4755,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,44 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +5922,48 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -4758,7 +4763,7 @@ diff --git a/client.js b/client.js + const face = isFaceRoom(here.room); + const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); + const bots = here.room ? [ -+ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(cleanPeerName(roster.self?.displayName, ""), bot.name, true) } : bot), ++ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot), + ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), + // A remote seat already listed under its handle (a duplicate participant record) is offered once. + ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ @@ -4781,6 +4786,10 @@ diff --git a/client.js b/client.js return rows; } +var isFaceRoom = (room2) => room2?.origin === "face-to-face"; ++var myOwnerName = (roster, live, bot) => { ++ const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); ++ return cleanPeerName(seat?.ownerName || roster.self?.displayName, ""); ++}; +var uniqueBy = (list, key) => { + const seen = /* @__PURE__ */ new Set(); + return list.filter((item) => { diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index bcba8271c..b8e1cef78 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1095,7 +1095,7 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js test('"@" offers a group\'s members and everyone, or every other Bot in a Bot\'s chat', () => { assert.deepEqual(names(mentionRows(roster, 'room')), ['Chief', 'King', 'everyone']) assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer']) -@@ -76,3 +76,44 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session +@@ -76,3 +76,47 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' }) assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), []) }) @@ -1136,6 +1136,9 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js + ['SelfA · Chief', undefined], + ['Everyone', 'Every Bot in this room'], + ]) ++ // Without a member-session actor (roster.self is null) the seat's own owner name is used, as Members does. ++ const legacy = indexRoster({ ...face, self: null, rooms: [{ ...face.rooms[0], participants: [{ ...face.rooms[0].participants[0], ownerName: 'SelfB' }, ...face.rooms[0].participants.slice(1)] }] }) ++ assert.equal(mentionRows(legacy, 'face')[0].bot.label, 'SelfB (you) · Chief') + // Outside a face room the published friends' Bots still join the menu. + const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] }) + assert.equal(mentionRows(group, 'face').filter(row => row.remote).length, 2)