diff --git a/changelog.d/hidden-remote-chat-sticks.fixed.md b/changelog.d/hidden-remote-chat-sticks.fixed.md new file mode 100644 index 000000000..06559e739 --- /dev/null +++ b/changelog.d/hidden-remote-chat-sticks.fixed.md @@ -0,0 +1 @@ +Hiding a friend's Bot chat from the sidebar ("Hide from sidebar") now sticks: the remote-chat sync no longer clears that hide on the next publish of the friends' list, and a hide the user set now survives the Bot leaving the published list and coming back, while a chat whose Bot left still disappears and a returning Bot still reappears ([#611](https://github.com/ThinkFlowLab/FlyBot/pull/611), [#631](https://github.com/ThinkFlowLab/FlyBot/issues/631)). diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 40aa529d5..0f46c5c3d 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -226,6 +226,13 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { + // `open()` clears `remote.gone` on a chat that is published again, so remember which chats + // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid + // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or + // every publish would bring it back, and a returning chat still reappears (its `gone` hide + // is dropped). + const goneBefore = new Map() + for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() for (const member of list) { if (isHomeLevelRemote(member)) continue @@ -236,13 +243,20 @@ export function install(rt) { for (const bot of chats()) { if (bot.remote.plugin !== plugin) continue const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) - const wasGone = Boolean(bot.remote.gone) - const wasHidden = Boolean(bot.hidden) - if (wasGone !== gone || wasHidden !== gone) { + const wasGone = goneBefore.get(bot.id) ?? Boolean(bot.remote.gone) + if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) bot.hidden = true - else delete bot.hidden + if (gone) { + // Remember when *we* hide the chat because the Bot left the published list: on `hidden` + // alone that is indistinguishable from a hide the user set, so the return below would + // drop the user's own hide too (#631). + if (bot.hidden !== true) bot.remote.hiddenByGone = true + bot.hidden = true + } else if (bot.remote.hiddenByGone === true) { + delete bot.hidden + delete bot.remote.hiddenByGone + } changed = true } } diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 9abf53b19..a00b799f2 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -62,6 +62,65 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.c.hidden, true) }) +test('sync keeps a chat the user hid while the Bot is still published', async () => { + const state = { + bots: { + a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, + { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }, + ]) + // A friend's chat hidden from the sidebar stays hidden; only leaving the published list does (#hide). + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) +}) + +// #631: `hidden` alone cannot tell "the user hid this" from "we hid this because the Bot left". +test('a hide the user set survives the Bot leaving the published list and coming back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + // The hide was the user's, so the return must not drop it. + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + +test('a hide imposed because the Bot left is dropped when it comes back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 31d7fe02a..85c953b1c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -192,6 +192,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). +- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). - `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 40aa529d5..0f46c5c3d 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -226,6 +226,13 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { + // `open()` clears `remote.gone` on a chat that is published again, so remember which chats + // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid + // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or + // every publish would bring it back, and a returning chat still reappears (its `gone` hide + // is dropped). + const goneBefore = new Map() + for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() for (const member of list) { if (isHomeLevelRemote(member)) continue @@ -236,13 +243,20 @@ export function install(rt) { for (const bot of chats()) { if (bot.remote.plugin !== plugin) continue const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) - const wasGone = Boolean(bot.remote.gone) - const wasHidden = Boolean(bot.hidden) - if (wasGone !== gone || wasHidden !== gone) { + const wasGone = goneBefore.get(bot.id) ?? Boolean(bot.remote.gone) + if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) bot.hidden = true - else delete bot.hidden + if (gone) { + // Remember when *we* hide the chat because the Bot left the published list: on `hidden` + // alone that is indistinguishable from a hide the user set, so the return below would + // drop the user's own hide too (#631). + if (bot.hidden !== true) bot.remote.hiddenByGone = true + bot.hidden = true + } else if (bot.remote.hiddenByGone === true) { + delete bot.hidden + delete bot.remote.hiddenByGone + } changed = true } } diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 9abf53b19..a00b799f2 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -62,6 +62,65 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.c.hidden, true) }) +test('sync keeps a chat the user hid while the Bot is still published', async () => { + const state = { + bots: { + a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, + { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }, + ]) + // A friend's chat hidden from the sidebar stays hidden; only leaving the published list does (#hide). + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) +}) + +// #631: `hidden` alone cannot tell "the user hid this" from "we hid this because the Bot left". +test('a hide the user set survives the Bot leaving the published list and coming back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + // The hide was the user's, so the return must not drop it. + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + +test('a hide imposed because the Bot left is dropped when it comes back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8')