From c74a85786e02d0a49c0ac008bb75f788731d61dc Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 14:08:32 +0800 Subject: [PATCH] Invite and friend-chat polish: friend Bot placeholder from first paint, one invite control, Add friend auto-close, Sharing hint (#499) --- README.md | 4 +- README.zh.md | 4 +- .../499-invite-friendchat-polish.fixed.md | 1 + core/client.js | 61 ++++---- core/src/client/composer-label.js | 41 +++++ core/src/client/header.js | 30 +--- core/test/composer-label.test.js | 70 +++++++++ core/test/looks.test.js | 9 +- docs/subsystems/core-client.md | 2 +- docs/subsystems/social.md | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 11 +- patches/ds-bot/0008-client-bundle.patch | 88 +++++------ patches/ds-bot/0012-room-membership.patch | 34 +---- .../ds-bot/files/src/client/composer-label.js | 41 +++++ .../ds-bot/files/test/composer-label.test.js | 70 +++++++++ plugins/social/client.js | 140 ++++++++++++------ plugins/social/test/social.test.js | 95 ++++++++++-- 17 files changed, 504 insertions(+), 201 deletions(-) create mode 100644 changelog.d/499-invite-friendchat-polish.fixed.md create mode 100644 core/src/client/composer-label.js create mode 100644 core/test/composer-label.test.js create mode 100644 patches/ds-bot/files/src/client/composer-label.js create mode 100644 patches/ds-bot/files/test/composer-label.test.js diff --git a/README.md b/README.md index 2dda9a19..857ade67 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu - **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). - **Members.** Access is a per-Bot **grant** (default deny, Chief included). A local Bot becomes `@.` on the server when you invite someone to that Bot, when it joins an invite, or when you bring it into a face-to-face room. The sidebar lists only Bots granted to you. **Manage access** on a Bot shows who has it and **Revoke**. Deleted Bots are revoked. Ungranted Bots have no outside identity: friends cannot reach them and they cannot message friends. - **Connectors (GitHub, Composio).** Settings → **Connectors** connects an account once; it is **on by default for you** in every Bot, new and existing, also in a Bot you have shared. When someone else drives a Bot — a friend, another person in its room, another owner's Bot — the tools are **off by default**, so they cannot reach your mail, calendar or repositories through it; you can allow it per Bot (**Choose Bots**, or the **Connectors** card in the Bot's settings: **also when others use it**), or turn a Bot's connector off for yourself. A room with other people in it counts as their use. Disconnecting resets every Bot's choices. -- **Friends in the Bot list.** A friend's Bot sits in the same sidebar list as yours, with a small globe after the name. Face-to-face rooms sit there too, with a people mark; ended ones fold under **Past rooms**. Click a row to open a normal chat. Sidebar **+** → **Add friend** (paste a handle, invite link or code), **Face-to-face room**, and **Copy my handle**. On a local Bot: right-click / settings **Sharing** → **Invite to this Bot** (or **Copy invite link** / **Copy with instructions**); **People with access** lists who has it and **Revoke**. Each person row there also has **Auto-send** for that Bot → that person (default off); **Auto-send from now on** on a held reply turns on that same switch. +- **Friends in the Bot list.** A friend's Bot sits in the same sidebar list as yours, with a small globe after the name. Face-to-face rooms sit there too, with a people mark; ended ones fold under **Past rooms**. Click a row to open a normal chat. Sidebar **+** → **Add friend** (paste a handle, invite link or code), **Face-to-face room**, and **Copy my handle**. On a local Bot: right-click / settings **Sharing** → **Copy invite link for **, then **Copy link** / **Copy with instructions** / **New link** (the same control as **Add friend** → **Invite a friend**); **People with access** lists who has it and **Revoke**. Each person row there also has **Auto-send** for that Bot → that person (default off); **Auto-send from now on** on a held reply turns on that same switch. - **Face-to-face rooms.** Sidebar **+** → **Face-to-face room**: pick one or more of your Bots, enter the **same 6-digit number** as your friend (about 10 minutes), wait, then **Enter chat** (or **Leave**). No prior friendship and no preview of the other home's other Bots. Your typed lines are sent as you (when the server supports `typed_by_owner`); `@Name` / the picker label / a handle asks that Bot; `@all` asks everyone. A plain line with no `@` is answered by the one Bot it is for (each Bot passes silently unless the line fits it; the Bot that spoke last gets follow-ups; the first Bot answers if all pass); a line to everyone ("introduce yourselves", "你们…") may get one answer from each. Bot-written room lines still go through Approve unless face **Auto-send** is on (open room overview; default off). - **Typed by the owner · mentions · thinking.** Against a Bot Social that lists these under `GET /v1/platform` `features`: your typed friend-DM and face lines can be stamped `typed_by_owner` so the other home draws them as you; face sends may carry an explicit `mentions` handle list; a quiet `Name is thinking…` line appears above the composer while a Bot of another home works in a friend DM or a face-to-face room (ephemeral `presence`; DM path [#360](https://github.com/ThinkFlowLab/FlyBot/pull/360) / [#332](https://github.com/ThinkFlowLab/FlyBot/issues/332)). Older servers simply omit what they do not support. - **Approvals.** Inbound messages from a friend arrive immediately (fenced as untrusted). Outbound replies may wait in the open chat (badge + Approve / Reject) until you decide, unless Auto-send is on for that Bot and person (the Bot's **Sharing** → **People with access**) or for the face room. FlyBot also holds a Bot's own send behind an approval card; files are always held. @@ -160,7 +160,7 @@ On first start FlyBot calls `POST /v1/onboard/flybot`, registers your ho **Change the server later** without editing YAML: Settings → **Bot Social** shows the server and its connection status, **Change server** checks the new address (`https://`, or `http://localhost` for development; FlyBot reads `/v1/platform` and shows its features) and, after a clear warning, switches without a restart. A new server is a **new identity**: your friends and sharing stay on the old server, your old credentials file is kept as a backup and never deleted, and switching back restores them. The `cordis.patch.yml` value stays the default. -**Share one Bot:** that Bot's settings → **Sharing** → **Invite to this Bot** (or **Copy invite link**); send the link. Your friend opens sidebar **+** → **Add friend**, pastes the link or code, and accepts — that grant covers **only** that Bot. **People with access** lists who has it; **Revoke** removes them. +**Share one Bot:** that Bot's settings → **Sharing** → **Copy invite link for ** (or **+** → **Add friend** → **Invite a friend**); send the link. Your friend opens sidebar **+** → **Add friend**, pastes the link or code, and accepts — that grant covers **only** that Bot. **People with access** lists who has it; **Revoke** removes them. **Chat:** click the friend's Bot in the sidebar. Their messages arrive at once. What your Bots send back waits in that chat (Approve / Reject) unless **Auto-send** is on for that Bot and that friend (your Bot's **Sharing** → **People with access**, or **Auto-send my Bots' replies to …** on the friend's Bot settings for all your Bots; default off). Look for the **to approve** badge if they see no reply. diff --git a/README.zh.md b/README.zh.md index 2f4f4105..f17f2ec4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -66,7 +66,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 - **成员。** 访问是按 Bot 的**授权**(默认拒绝,Chief 也一样)。本地 Bot 在你邀请别人使用它、它加入邀请、或你把它带进面对面房间时,才会在服务器上成为 `@.`。侧栏只列出别人授权给你的 Bot。每个 Bot 的 **管理访问** 列出谁有它,并可 **撤销**。删除 Bot 会撤销其身份。未授权的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 - **连接器(GitHub、Composio)。** 设置 → **连接器** 里连接一次账号;**默认对你自己开启**,每个 Bot(新建的和已有的)都能用,共享出去的 Bot 你自己也照样能用。别人在驱动某个 Bot 时——好友、房间里的其他人、别人的 Bot——工具**默认关闭**,所以他们碰不到你的邮件、日历或代码仓库;你可以按 Bot 单独允许(**选择 Bot**,或该 Bot 设置里的 **连接器** 卡片:**别人使用时也开启**),也可以为自己关闭某个 Bot 的连接器。有其他人在的房间里也算别人使用。断开连接会重置所有 Bot 的选择。 -- **侧栏里的好友。** 好友的 Bot 和你自己的 Bot 在同一列表,名字后面有一个小地球标记。面对面房间也在这份列表里,带一个小人标记;已结束的折在 **过去的房间** 下。点开就是普通对话。侧栏 **+** → **添加好友**(可粘贴句柄、邀请链接或码)、**面对面房间**、**复制我的 handle**。本地 Bot:设置里 **Sharing(分享)** → **Invite to this Bot(邀请使用此 Bot)**(或 **复制邀请链接** / 带说明的邀请);**People with access(访问权限)** 列出谁有它并可 **Revoke(撤销)**。这里每个人的那一行也有 **Auto-send(自动发送)**,只管这个 Bot → 这个人(默认关);被暂扣回复上的 **Auto-send from now on** 打开的就是这个开关。 +- **侧栏里的好友。** 好友的 Bot 和你自己的 Bot 在同一列表,名字后面有一个小地球标记。面对面房间也在这份列表里,带一个小人标记;已结束的折在 **过去的房间** 下。点开就是普通对话。侧栏 **+** → **添加好友**(可粘贴句柄、邀请链接或码)、**面对面房间**、**复制我的 handle**。本地 Bot:设置里 **Sharing(分享)** → **Copy invite link for (复制邀请链接)**,之后可 **Copy link** / **Copy with instructions**(带说明)/ **New link**(与 **添加好友** → **Invite a friend** 是同一个控件);**People with access(访问权限)** 列出谁有它并可 **Revoke(撤销)**。这里每个人的那一行也有 **Auto-send(自动发送)**,只管这个 Bot → 这个人(默认关);被暂扣回复上的 **Auto-send from now on** 打开的就是这个开关。 - **面对面房间。** 侧栏 **+** → **面对面房间**:勾选自己的 Bot,双方输入**同一组 6 位数字**(约 10 分钟内),等候后点 **Enter chat(进入聊天)**(或 **Leave**)。不必先成为好友,也不会暴露对方家里的其他 Bot。你打的字在对方看来是你本人(服务器支持 `typed_by_owner` 时);`@名字` / 选择器标签 / handle 点名该 Bot;`@all` 叫全员。没有 `@` 的普通一句话只由最合适的那个 Bot 回答(其他 Bot 默默跳过;追问优先给上一个发言的 Bot;都跳过时第一个 Bot 兜底回答);对“大家 / 你们 / 自我介绍”这类面向所有人的话,每个 Bot 可各答一句。Bot 自己写的房间消息仍可能进审批,除非在该房间概览开了面对面 **Auto-send**(默认关)。 - **主人手打 · 提及 · 正在输入。** 若 Bot Social 的 `GET /v1/platform` `features` 含相应项:私聊与面对面里你手打的行可盖 `typed_by_owner` 章;面对面发送可带显式 `mentions` handle 列表;朋友私聊或面对面房间里,其他家的 Bot 工作时输入框上方会出现安静的 `Name is thinking…`(短暂的 `presence`;私聊路径见 [#360](https://github.com/ThinkFlowLab/FlyBot/pull/360) / [#332](https://github.com/ThinkFlowLab/FlyBot/issues/332))。旧服务器没有的能力会自动跳过。 - **审批。** 好友发来的消息会立刻送达(按不可信内容处理)。你的 Bot 发出的回复可能停在当前聊天里等你批准(或拒绝),除非对这个 Bot 和这个人开了自动发送(该 Bot 的 **Sharing** → **People with access**),或该面对面房间开了自动发送。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 @@ -154,7 +154,7 @@ export PORT=8787 **之后换服务器**不用改 YAML:设置 → **Bot Social** 显示当前服务器和连接状态,**更换服务器**会先检查新地址(`https://`;开发时可用 `http://localhost`;FlyBot 读取 `/v1/platform` 并列出它的功能),经过明确的警告确认后无需重启即可切换。换服务器等于**换一个新身份**:好友和分享留在旧服务器上,旧的凭证文件作为备份保留、不会被删除,切换回去即可恢复。`cordis.patch.yml` 里的值仍是默认值。 -**分享某一个 Bot:** 该 Bot 设置 → **Sharing** → **Invite to this Bot**(或 **复制邀请链接**),把链接发给对方;对方侧栏 **+** → **添加好友** 粘贴链接或码并接受——**只授权这一个 Bot**。**People with access** 可查看并 **Revoke**。 +**分享某一个 Bot:** 该 Bot 设置 → **Sharing** → **Copy invite link for **(或 **+** → **添加好友** → **Invite a friend**),把链接发给对方;对方侧栏 **+** → **添加好友** 粘贴链接或码并接受——**只授权这一个 Bot**。**People with access** 可查看并 **Revoke**。 **私聊:** 点侧栏里对方的 Bot。对方来信立刻到。你这边发出去的内容停在该聊天等批准(除非对这个 Bot 和该好友开了 **Auto-send**:你的 Bot 的 **Sharing** → **People with access**;或在好友 Bot 设置里勾 **Auto-send my Bots' replies to …**,覆盖你所有 Bot;默认关)。对方看不到回复时,先看 **to approve / 待批准** 角标。 diff --git a/changelog.d/499-invite-friendchat-polish.fixed.md b/changelog.d/499-invite-friendchat-polish.fixed.md new file mode 100644 index 00000000..0092c2f3 --- /dev/null +++ b/changelog.d/499-invite-friendchat-polish.fixed.md @@ -0,0 +1 @@ +Invites and friend chats are tidier. A friend's Bot chat always reads **Message Chief (Alice)** in the composer, from the first open and after every send, never the coding-agent copy. **Add friend** and a Bot's **Sharing** use one invite control: **Copy invite link for Chief** makes and copies the link, which then shows with **Copy link**, **Copy with instructions** and **New link**, and every copy says which Bot it is for (`Invite link for Chief copied.`). The Add friend paste box wraps, so a whole pasted link is readable; typing clears an old error, and after an accepted invite the panel shows the result for a moment, then closes and opens the new friend's Bot. The Sharing hint mentions Revoke only once someone has access ([#499](https://github.com/ThinkFlowLab/FlyBot/issues/499)). diff --git a/core/client.js b/core/client.js index a5051ae9..46a79f9f 100644 --- a/core/client.js +++ b/core/client.js @@ -7769,6 +7769,38 @@ var SIDEBAR_CSS = ` .bt-row-settings svg{width:15px;height:15px} `; +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { + doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); + const shell = /* @__PURE__ */ new Map(); + const apply2 = () => { + for (const el of doc.querySelectorAll("[data-composer-input]")) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name); + if (current === label) continue; + if (!shell.has(el)) shell.set(el, {}); + shell.get(el)[name] = current; + el.setAttribute(name, label); + } + } + }; + apply2(); + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; + const watcher = typeof Observer === "function" ? new Observer(apply2) : null; + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); + return () => { + watcher?.disconnect(); + for (const [el, values] of shell) { + if (!el.isConnected) continue; + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } + }; +} + // src/client/header.js var import_react13 = require("react"); var import_react_dom4 = require("react-dom"); @@ -7852,34 +7884,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe delete document.body.dataset.btFriendChat; }; }, [bot?.remote]); - (0, import_react13.useEffect)(() => { - document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); - const input = document.querySelector("[data-composer-input]"); - const priorLabel = input?.getAttribute("aria-label") ?? null; - const apply2 = () => { - const el = document.querySelector("[data-composer-input]"); - if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); - }; - apply2(); - const timer = setTimeout(apply2, 0); - let frame = 0; - const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(() => { - frame = 0; - apply2(); - }); - }) : null; - watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); - return () => { - clearTimeout(timer); - watcher?.disconnect(); - if (frame) cancelAnimationFrame(frame); - const el = document.querySelector("[data-composer-input]"); - if (!el) return; - if (priorLabel == null) el.removeAttribute("aria-label"); - else el.setAttribute("aria-label", priorLabel); - }; - }, [composerLabel]); + (0, import_react13.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); (0, import_react13.useEffect)(() => { actions.beginVisit(sessionId); return () => actions.endVisit(sessionId); diff --git a/core/src/client/composer-label.js b/core/src/client/composer-label.js new file mode 100644 index 00000000..2dc45b99 --- /dev/null +++ b/core/src/client/composer-label.js @@ -0,0 +1,41 @@ +// The composer's label in a FlyBot chat (#419, #499). The shell picks its placeholder by phase, +// before it knows whose chat this is: the new-session hero copy ("Describe what you want to +// build, …") on a first open and its coding copy ("Message or run a task, …") after a send. A +// FlyBot chat, a friend's Bot above all, always reads "Message ": the visible placeholder +// is --bt-placeholder (styles.js draws it over the shell's text), and the input's aria-label and +// data-placeholder are put back whenever the shell renders them again. The mutation callback +// rewrites them in the same task, so no frame paints the shell's copy. +const ATTRIBUTES = ['aria-label', 'data-placeholder'] + +// Holds `label` on every composer input in `doc` until the returned function is called, which +// stops watching and gives each input the shell's latest own value back. +export function holdComposerLabel(doc, label) { + doc.body.style.setProperty('--bt-placeholder', JSON.stringify(label)) + // The shell's own value per input and attribute, newest first, for the hand-back. + const shell = new Map() + const apply = () => { + for (const el of doc.querySelectorAll('[data-composer-input]')) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name) + if (current === label) continue + if (!shell.has(el)) shell.set(el, {}) + shell.get(el)[name] = current + el.setAttribute(name, label) + } + } + } + apply() + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver + const watcher = typeof Observer === 'function' ? new Observer(apply) : null + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }) + return () => { + watcher?.disconnect() + for (const [el, values] of shell) { + if (!el.isConnected) continue + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name) + else el.setAttribute(name, value) + } + } + } +} diff --git a/core/src/client/header.js b/core/src/client/header.js index 86f0b8c8..f0f38c56 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -7,6 +7,7 @@ import { ActivityIndicator } from './activity-row.js' import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header @@ -101,33 +102,8 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus document.body.dataset.btFriendChat = '' return () => { delete document.body.dataset.btFriendChat } }, [bot?.remote]) - useEffect(() => { - document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) - const input = document.querySelector('[data-composer-input]') - const priorLabel = input?.getAttribute('aria-label') ?? null - const apply = () => { - const el = document.querySelector('[data-composer-input]') - if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) - } - apply() - const timer = setTimeout(apply, 0) - // The shell re-renders its composer after a send and puts its own coding label back - // ("Message or run a task, / commands, …"); keep "Message " on it (#419). - let frame = 0 - const watcher = typeof MutationObserver === 'function' - ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) - : null - watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) - return () => { - clearTimeout(timer) - watcher?.disconnect() - if (frame) cancelAnimationFrame(frame) - const el = document.querySelector('[data-composer-input]') - if (!el) return - if (priorLabel == null) el.removeAttribute('aria-label') - else el.setAttribute('aria-label', priorLabel) - } - }, [composerLabel]) + // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). + useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) useEffect(() => { actions.beginVisit(sessionId) return () => actions.endVisit(sessionId) diff --git a/core/test/composer-label.test.js b/core/test/composer-label.test.js new file mode 100644 index 00000000..7fa3434b --- /dev/null +++ b/core/test/composer-label.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { holdComposerLabel } from '../src/client/composer-label.js' + +// jsdom comes from the DSH harness, like the dsh packages (test/harness-resolve.mjs sets DSH_HARNESS). +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') + +// The shell's own composer copy (dsh-client-ui-conversation: placeholder.hero / placeholder.default). +const HERO = 'Describe what you want to build, / commands, @ files or sessions' +const CODING = 'Message or run a task, / commands, @ files or sessions' +const CODING_WORDS = /build|commands|@ files|sessions|run a task/ + +// The composer the shell renders: the input carries the copy as aria-label and data-placeholder, +// and an aria-hidden placeholder element shows it (styles.js draws --bt-placeholder over it). +function composer(doc, copy) { + const el = doc.createElement('div') + el.setAttribute('data-composer-input', '') + el.setAttribute('role', 'textbox') + el.setAttribute('aria-label', copy) + el.setAttribute('data-placeholder', copy) + return el +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) +const shown = (doc) => { + const el = doc.querySelector('[data-composer-input]') + return { aria: el.getAttribute('aria-label'), placeholder: el.getAttribute('data-placeholder'), css: JSON.parse(doc.body.style.getPropertyValue('--bt-placeholder')) } +} + +test('#499: a friend\'s Bot chat reads "Message " from the first paint, before and after a send', async () => { + const { window } = new JSDOM('
') + const doc = window.document + const label = 'Message Chief (SelfA1233)' + // First open: the shell renders its new-session hero copy. + doc.querySelector('main').append(composer(doc, HERO)) + const release = holdComposerLabel(doc, label) + // Synchronously, as in the header's layout effect: nothing is painted with the hero copy. + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // A send: the shell re-renders the same input with its coding copy. + const input = doc.querySelector('[data-composer-input]') + input.setAttribute('aria-label', CODING) + input.setAttribute('data-placeholder', CODING) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // The shell mounts a fresh composer (phase change or reload of the view). + input.replaceWith(composer(doc, CODING)) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + for (const value of Object.values(shown(doc))) assert.doesNotMatch(value, CODING_WORDS) + // Leaving the chat hands the shell its own latest copy back and stops watching. + release() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), CODING) + doc.querySelector('[data-composer-input]').setAttribute('aria-label', HERO) + await settle() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), HERO) +}) + +test('#499: the next chat\'s label takes over without a flash of the previous one', async () => { + const { window } = new JSDOM('') + const doc = window.document + doc.body.append(composer(doc, HERO)) + const first = holdComposerLabel(doc, 'Message Writer') + first() + const second = holdComposerLabel(doc, 'Message Chief (SelfB1233)') + assert.equal(shown(doc).aria, 'Message Chief (SelfB1233)') + await settle() + assert.equal(shown(doc).placeholder, 'Message Chief (SelfB1233)') + second() +}) diff --git a/core/test/looks.test.js b/core/test/looks.test.js index db508154..ec8c0aac 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -61,10 +61,11 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se // #276: gear is on every wide Bot row (own and friends'), not only remotes. assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) - assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) - // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. - assert.match(text('header.js'), /new MutationObserver\(/) - assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) + // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders + // it (composer-label.test.js); a friend's Bot chat hides the stats footer. + assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) + assert.match(text('composer-label.js'), /new Observer\(apply\)/) + assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..061daead 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -83,7 +83,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). -- `HeaderPill` sets the composer placeholder CSS var and mirrors the localized **Message {name}** string on `[data-composer-input]`'s `aria-label`; cleanup restores the prior label when the header unmounts or the session changes (#166). The shell re-renders its composer after a send and puts its own coding label back, so a `MutationObserver` (rAF-throttled, `aria-label` and child changes) keeps **Message {name}** on it while the chat is open (#419). In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. +- `HeaderPill` holds the localized **Message {name}** label on the composer with `holdComposerLabel` (`src/client/composer-label.js`) in a layout effect, so it is in place before the first paint: it sets the placeholder CSS var and writes the label on every `[data-composer-input]`'s `aria-label` and `data-placeholder`; cleanup hands each input the shell's latest own value back when the header unmounts or the session changes (#166). The shell picks its copy by phase before it knows whose chat it is (the new-session hero copy on a first open, its coding copy after a send), so a `MutationObserver` (those two attributes and child changes) rewrites them in its own callback, before the next paint, while the chat is open (#419, #499); `test/composer-label.test.js` drives it on a jsdom composer. In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. ### Face-room held bubble and overview diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda..e8756c61 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -90,7 +90,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - #191: a peer's leave reads `Bob left the room` (the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (`FaceRoomStatus` on `flybot.session.dock`, `data-flybot=face-room-end`: `Bob left the room. This room is closed.` / `You left this room.`). Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. - #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has `data-flybot=face-room-end`), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section: `relationship_exists` reads `You're already friends. Their Bot is back in your list.`, and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. - My own handle (`@name.home`) shows with a **Copy** link at the top of the account menu (slot `flybot.account.header`, `MyHandle`) as well as in Add friend (#181). -- Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then **Copy invite link for {Bot}**: mint + clipboard, and the link stays in a read-only field below it), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). After **Accept invite** the panel stays open with the result until **Done**: `{person} shared {Bot} with you.`, or, when the join brought no Bot, `Joined {person}'s invite, but their Bot hasn't loaded yet…` (#343). **Talk through** stays on one line (nowrap label). Bot Sharing puts the invite link on its own full-width row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button (#230); before the first link there is only **Invite to this Bot**. The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. +- Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, which wraps so a whole pasted link shows and accepts on Enter (#499), **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then the shared invite control below), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). Typing in the paste box clears a failed accept's error (#499). After **Accept invite** the result shows: `{person} shared {Bot} with you.`, and about 1.8 s later the panel closes on its own and opens the new friend's Bot when its chat is already listed (#499); when the join brought no Bot (`Joined {person}'s invite, but their Bot hasn't loaded yet…`) or sent a request, the panel stays open until **Done** (#343). **Talk through** stays on one line (nowrap label). Add friend and Bot Sharing draw one invite control (`inviteControls` in `client.js`, #499): before a link there is one button, **Copy invite link for {Bot}** (mint + clipboard); then the link sits on its own full-width read-only row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button that makes a fresh link without copying it (#230). Every copy note names the Bot: `Invite link for {Bot} copied.` / `Invite for {Bot} copied with instructions.`. The Sharing hint is `Invite grants {Bot} only.` and adds `Revoke anytime in People with access.` only once People with access lists someone (`ManageAccess` reports its count through `onGrants`); the empty list says `Nobody has this Bot yet. Copy an invite link above and send it to a friend.` (#499). The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. ### Face-room human turns and UI polish (#166, #167) @@ -110,7 +110,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. -- The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. +- The composer **Message {name}** label is held by core `HeaderPill` through `composer-label.js` (placeholder CSS var plus `aria-label` and `data-placeholder` on `[data-composer-input]`, from the first paint, restored on cleanup; #419, #499); chat-header tok jargon stays in pristine DSH / ds-bot. ### Face-room poll identity (#122) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e02c40ed..e44ed75c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2577,7 +2577,7 @@ diff --git a/test/looks.test.js b/test/looks.test.js import { indexRoster } from '../src/client/sources.js' const ids = Array.from({ length: 3000 }, (_, index) => `session-${index.toString(16).padStart(8, '0')}-look`) -@@ -29,19 +29,103 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl +@@ -29,19 +29,104 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl assert.equal(photo.shape, defaultShape('chief')) }) @@ -2617,10 +2617,11 @@ diff --git a/test/looks.test.js b/test/looks.test.js + // #276: gear is on every wide Bot row (own and friends'), not only remotes. + assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) + assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) -+ assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) -+ // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. -+ assert.match(text('header.js'), /new MutationObserver\(/) -+ assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) ++ // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders ++ // it (composer-label.test.js); a friend's Bot chat hides the stats footer. ++ assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) ++ assert.match(text('composer-label.js'), /new Observer\(apply\)/) ++ assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) + assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) + assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..4bd45544 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2329,12 +2329,10 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); +@@ -2854,227 +2854,209 @@ function installCompiledStyles(text, tag) { style.remove(); -- }; --} + }; + } - -// src/client/mark.js -var import_react2 = require("react"); @@ -2419,8 +2417,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -3849,7 +3846,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +3942,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,12 +4369,12 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- 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 }); ++ } + 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_react8.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_react8.createElement)(RemoteMessage, { @@ -5975,7 +5972,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5491,28 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,60 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -5991,6 +5988,38 @@ diff --git a/client.js b/client.js +.bt-row-settings svg{width:15px;height:15px} `; ++// src/client/composer-label.js ++var ATTRIBUTES = ["aria-label", "data-placeholder"]; ++function holdComposerLabel(doc, label) { ++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); ++ const shell = /* @__PURE__ */ new Map(); ++ const apply2 = () => { ++ for (const el of doc.querySelectorAll("[data-composer-input]")) { ++ for (const name of ATTRIBUTES) { ++ const current = el.getAttribute(name); ++ if (current === label) continue; ++ if (!shell.has(el)) shell.set(el, {}); ++ shell.get(el)[name] = current; ++ el.setAttribute(name, label); ++ } ++ } ++ }; ++ apply2(); ++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; ++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; ++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); ++ return () => { ++ watcher?.disconnect(); ++ for (const [el, values] of shell) { ++ if (!el.isConnected) continue; ++ for (const [name, value] of Object.entries(values)) { ++ if (value == null) el.removeAttribute(name); ++ else el.setAttribute(name, value); ++ } ++ } ++ }; ++} ++ // src/client/header.js -var import_react9 = require("react"); +var import_react13 = require("react"); @@ -6039,7 +6068,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,102 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +5548,75 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -6077,34 +6106,7 @@ diff --git a/client.js b/client.js + delete document.body.dataset.btFriendChat; + }; + }, [bot?.remote]); -+ (0, import_react13.useEffect)(() => { -+ document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); -+ const input = document.querySelector("[data-composer-input]"); -+ const priorLabel = input?.getAttribute("aria-label") ?? null; -+ const apply2 = () => { -+ const el = document.querySelector("[data-composer-input]"); -+ if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); -+ }; -+ apply2(); -+ const timer = setTimeout(apply2, 0); -+ let frame = 0; -+ const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(() => { -+ frame = 0; -+ apply2(); -+ }); -+ }) : null; -+ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); -+ return () => { -+ clearTimeout(timer); -+ watcher?.disconnect(); -+ if (frame) cancelAnimationFrame(frame); -+ const el = document.querySelector("[data-composer-input]"); -+ if (!el) return; -+ if (priorLabel == null) el.removeAttribute("aria-label"); -+ else el.setAttribute("aria-label", priorLabel); -+ }; -+ }, [composerLabel]); ++ (0, import_react13.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); + (0, import_react13.useEffect)(() => { actions.beginVisit(sessionId); return () => actions.endVisit(sessionId); diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 5c949605..5836487d 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -485,7 +485,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js diff --git a/src/client/header.js b/src/client/header.js --- a/src/client/header.js +++ b/src/client/header.js -@@ -4,8 +4,9 @@ import { currentPart, latestMemoryChange } from './text.js' +@@ -4,8 +4,10 @@ import { currentPart, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' import { ActivityIndicator } from './activity-row.js' @@ -493,10 +493,11 @@ diff --git a/src/client/header.js b/src/client/header.js +import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' ++import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header -@@ -68,20 +68,72 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -68,20 +68,47 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) @@ -535,33 +536,8 @@ diff --git a/src/client/header.js b/src/client/header.js + document.body.dataset.btFriendChat = '' + return () => { delete document.body.dataset.btFriendChat } + }, [bot?.remote]) -+ useEffect(() => { -+ document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) -+ const input = document.querySelector('[data-composer-input]') -+ const priorLabel = input?.getAttribute('aria-label') ?? null -+ const apply = () => { -+ const el = document.querySelector('[data-composer-input]') -+ if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) -+ } -+ apply() -+ const timer = setTimeout(apply, 0) -+ // The shell re-renders its composer after a send and puts its own coding label back -+ // ("Message or run a task, / commands, …"); keep "Message " on it (#419). -+ let frame = 0 -+ const watcher = typeof MutationObserver === 'function' -+ ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) -+ : null -+ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) -+ return () => { -+ clearTimeout(timer) -+ watcher?.disconnect() -+ if (frame) cancelAnimationFrame(frame) -+ const el = document.querySelector('[data-composer-input]') -+ if (!el) return -+ if (priorLabel == null) el.removeAttribute('aria-label') -+ else el.setAttribute('aria-label', priorLabel) -+ } -+ }, [composerLabel]) ++ // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). ++ useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) useEffect(() => { actions.beginVisit(sessionId) return () => actions.endVisit(sessionId) diff --git a/patches/ds-bot/files/src/client/composer-label.js b/patches/ds-bot/files/src/client/composer-label.js new file mode 100644 index 00000000..2dc45b99 --- /dev/null +++ b/patches/ds-bot/files/src/client/composer-label.js @@ -0,0 +1,41 @@ +// The composer's label in a FlyBot chat (#419, #499). The shell picks its placeholder by phase, +// before it knows whose chat this is: the new-session hero copy ("Describe what you want to +// build, …") on a first open and its coding copy ("Message or run a task, …") after a send. A +// FlyBot chat, a friend's Bot above all, always reads "Message ": the visible placeholder +// is --bt-placeholder (styles.js draws it over the shell's text), and the input's aria-label and +// data-placeholder are put back whenever the shell renders them again. The mutation callback +// rewrites them in the same task, so no frame paints the shell's copy. +const ATTRIBUTES = ['aria-label', 'data-placeholder'] + +// Holds `label` on every composer input in `doc` until the returned function is called, which +// stops watching and gives each input the shell's latest own value back. +export function holdComposerLabel(doc, label) { + doc.body.style.setProperty('--bt-placeholder', JSON.stringify(label)) + // The shell's own value per input and attribute, newest first, for the hand-back. + const shell = new Map() + const apply = () => { + for (const el of doc.querySelectorAll('[data-composer-input]')) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name) + if (current === label) continue + if (!shell.has(el)) shell.set(el, {}) + shell.get(el)[name] = current + el.setAttribute(name, label) + } + } + } + apply() + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver + const watcher = typeof Observer === 'function' ? new Observer(apply) : null + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }) + return () => { + watcher?.disconnect() + for (const [el, values] of shell) { + if (!el.isConnected) continue + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name) + else el.setAttribute(name, value) + } + } + } +} diff --git a/patches/ds-bot/files/test/composer-label.test.js b/patches/ds-bot/files/test/composer-label.test.js new file mode 100644 index 00000000..7fa3434b --- /dev/null +++ b/patches/ds-bot/files/test/composer-label.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { holdComposerLabel } from '../src/client/composer-label.js' + +// jsdom comes from the DSH harness, like the dsh packages (test/harness-resolve.mjs sets DSH_HARNESS). +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') + +// The shell's own composer copy (dsh-client-ui-conversation: placeholder.hero / placeholder.default). +const HERO = 'Describe what you want to build, / commands, @ files or sessions' +const CODING = 'Message or run a task, / commands, @ files or sessions' +const CODING_WORDS = /build|commands|@ files|sessions|run a task/ + +// The composer the shell renders: the input carries the copy as aria-label and data-placeholder, +// and an aria-hidden placeholder element shows it (styles.js draws --bt-placeholder over it). +function composer(doc, copy) { + const el = doc.createElement('div') + el.setAttribute('data-composer-input', '') + el.setAttribute('role', 'textbox') + el.setAttribute('aria-label', copy) + el.setAttribute('data-placeholder', copy) + return el +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) +const shown = (doc) => { + const el = doc.querySelector('[data-composer-input]') + return { aria: el.getAttribute('aria-label'), placeholder: el.getAttribute('data-placeholder'), css: JSON.parse(doc.body.style.getPropertyValue('--bt-placeholder')) } +} + +test('#499: a friend\'s Bot chat reads "Message " from the first paint, before and after a send', async () => { + const { window } = new JSDOM('
') + const doc = window.document + const label = 'Message Chief (SelfA1233)' + // First open: the shell renders its new-session hero copy. + doc.querySelector('main').append(composer(doc, HERO)) + const release = holdComposerLabel(doc, label) + // Synchronously, as in the header's layout effect: nothing is painted with the hero copy. + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // A send: the shell re-renders the same input with its coding copy. + const input = doc.querySelector('[data-composer-input]') + input.setAttribute('aria-label', CODING) + input.setAttribute('data-placeholder', CODING) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // The shell mounts a fresh composer (phase change or reload of the view). + input.replaceWith(composer(doc, CODING)) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + for (const value of Object.values(shown(doc))) assert.doesNotMatch(value, CODING_WORDS) + // Leaving the chat hands the shell its own latest copy back and stops watching. + release() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), CODING) + doc.querySelector('[data-composer-input]').setAttribute('aria-label', HERO) + await settle() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), HERO) +}) + +test('#499: the next chat\'s label takes over without a flash of the previous one', async () => { + const { window } = new JSDOM('') + const doc = window.document + doc.body.append(composer(doc, HERO)) + const first = holdComposerLabel(doc, 'Message Writer') + first() + const second = holdComposerLabel(doc, 'Message Chief (SelfB1233)') + assert.equal(shown(doc).aria, 'Message Chief (SelfB1233)') + await settle() + assert.equal(shown(doc).placeholder, 'Message Chief (SelfB1233)') + second() +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index b206641d..2e0e7e44 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -123,6 +123,8 @@ window.__ModuleLoader__.load({ .fs-sharing{display:flex;flex-direction:column;gap:10px} .fs-label-plain{text-transform:none;letter-spacing:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink,#141414)} .fs-invite-link{width:100%;box-sizing:border-box;min-width:0} +/* The paste box wraps, so a whole pasted link is readable at the sidebar's width (#499). */ +.fs-paste{flex:0 0 auto;width:100%;box-sizing:border-box;height:auto;min-height:30px;max-height:96px;padding:6px 9px;line-height:17px;resize:none;overflow-wrap:anywhere;word-break:break-all;field-sizing:content} .fs-invite-actions{flex-wrap:wrap} .fs-btn-fit{width:auto} .fs-share-request .fs-line{justify-content:flex-end} @@ -1654,7 +1656,7 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, h(ShareRequests, { items: shareRequests, actions, onDismiss: dismissShareRequest }), h(FaceRooms, { actions, enabled: true, roster, remoteChats }), - adding ? h(AddFriend, { actions, onChange: loadApprovals, onDone: () => setAdding(false) }) : null, + adding ? h(AddFriend, { actions, remoteChats, onChange: loadApprovals, onDone: () => setAdding(false) }) : null, faceOpen ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null) } @@ -1682,8 +1684,36 @@ window.__ModuleLoader__.load({ })) } + // One invite control for Add friend and a Bot's Sharing (#499): one button makes the link and copies + // it; then the link shows with the same Copy link / Copy with instructions / New link, and every copy + // note names the Bot. A plain render helper without hooks: each caller keeps its own state. + const INVITE_COPY_FAILED = 'Could not copy. Select the link and copy it by hand.' + const inviteCopied = (botName, withText) => (withText ? `Invite for ${botName} copied with instructions.` : `Invite link for ${botName} copied.`) + // Call it straight from a click where possible, so the clipboard write keeps its permission. + const copyInvite = (text, botName, withText = false) => { + const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined + if (!text || !clip?.writeText) return Promise.resolve({ tone: 'error', text: INVITE_COPY_FAILED }) + return Promise.resolve(clip.writeText(text)).then(() => ({ tone: 'ok', text: inviteCopied(botName, withText) }), () => ({ tone: 'error', text: INVITE_COPY_FAILED })) + } + function inviteControls({ botName, invite, busy, onMint, onCopy, onNew } = {}) { + // The button names the Bot the link shares; a long name ends in an ellipsis instead of being clipped. + if (!invite) { + return h('div', { className: 'fs-line', 'data-flybot': 'invite-line' }, + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'mint-invite', title: `Copy invite link for ${botName}`, style: { flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, disabled: busy, onClick: onMint }, `Copy invite link for ${botName}`)) + } + // The link first, then Copy link as the one primary action; a new link is a quiet text button (#230). + return h(Fragment, null, + h('input', { className: 'fs-input fs-invite-link', readOnly: true, value: invite.link, 'aria-label': `Invite link for ${botName}`, 'data-flybot': 'invite-link', onFocus: e => e.target.select?.(), onClick: e => e.target.select?.() }), + h('div', { className: 'fs-line fs-invite-actions' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-primary fs-btn-fit', 'data-flybot': 'copy-link', onClick: () => onCopy(invite.link, false) }, 'Copy link'), + invite.text ? h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'copy-invite-text', onClick: () => onCopy(invite.text, true) }, 'Copy with instructions') : null, + h('button', { type: 'button', className: 'fs-link fs-invite-new', 'data-flybot': 'new-invite-link', disabled: busy, onClick: onNew }, 'New link'))) + } + + // How long an accepted invite's result shows before Add friend closes on its own (#499). + const ACCEPT_CLOSE_MS = 1800 // Add friend: accept a friend's invite, invite a friend to one of my Bots, and answer requests. - function AddFriend({ onChange, onDone } = {}) { + function AddFriend({ actions, remoteChats, onChange, onDone } = {}) { const [overview, setOverview] = useState(null) const [code, setCode] = useState('') const [status, setStatus] = useState(null) @@ -1696,6 +1726,10 @@ window.__ModuleLoader__.load({ const [me, setMe] = useState('') // The link that was just made, kept on screen so it can be checked or copied by hand (#343). const [made, setMade] = useState(null) + // The friend chats as of now, read when the panel closes after an accept; the pending close (#499). + const chats = useRef(remoteChats) + chats.current = remoteChats + const closing = useRef(null) // `keep`: a result is on screen (an accepted invite, say); a failed re-read of the friend list must not // replace it with an error that looks like the action failed (#343). const refresh = (keep = false) => call('social/overview', {}).then(setOverview, (e) => { if (!keep) setStatus({ tone: 'error', text: errorText(e) }) }) @@ -1717,6 +1751,7 @@ window.__ModuleLoader__.load({ document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [onDone]) + useEffect(() => () => clearTimeout(closing.current), []) const run = async (label, fn) => { setBusy(true) setStatus(null) @@ -1732,39 +1767,44 @@ window.__ModuleLoader__.load({ setBusy(false) } } - const mint = () => run('Invite', async () => { + // Makes a link for the picked Bot and copies it; New link (`copy` false) only replaces the link shown. + const mint = (copy = true) => run('Invite', async () => { const botId = sharing || mine.find(bot => bot.isMain)?.id || mine[0]?.id if (!botId) throw new Error('Choose one of your Bots to invite a friend to') const botName = mine.find(bot => bot.id === botId)?.name || 'your Bot' const invite = await call('social/create-invite', { botId }) const link = invite?.link || invite?.url if (!link) throw new Error('The invite came back without its link') - setMade({ link, botName }) - const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined - if (clip?.writeText) { - try { - await clip.writeText(link) - return `Invite link for ${botName} copied. Send it to a friend.` - } catch { - return `Could not copy. Copy the link below and send it to a friend.` - } - } - return 'Copy the link below and send it to a friend.' + setMade({ link, text: invite?.text, botName }) + return copy ? (await copyInvite(link, botName)).text : '' }) - const join = e => { + const copyMade = (text, withText) => { if (made) void copyInvite(text, made.botName, withText).then(setStatus) } + const join = async e => { e.preventDefault() - return run('Accepting invite', async () => { + if (busy || !code.trim()) return + let accepted + await run('Accepting invite', async () => { const r = await call('social/join-invite', { code, ...(joiner ? { botId: joiner } : {}) }) setCode('') const who = r.inviterName ?? r.inviter ?? 'Your friend' const shared = r.inviterBotName || r.sharedBot if (r.status === 'active') { - // The panel stays open with the result until Done, so it can be read (#343). A join that - // brought no Bot says so instead of claiming one was shared. + // A join that brought no Bot says so instead of claiming one was shared, and stays open. + if (shared) accepted = r return shared ? `${who} shared ${shared} with you.` : `Joined ${who}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.` } return `Request sent to ${who}. They show up here once they accept.` }) + // The result shows for a moment, then the panel closes on its own and opens the new friend's + // Bot when it is already listed: no second click on Done (#499). + if (!accepted || !onDone) return + clearTimeout(closing.current) + closing.current = setTimeout(() => { + closing.current = null + const chat = chatByHandle(chats.current, accepted.inviter) + onDone() + if (chat?.id && typeof actions?.openSession === 'function') actions.openSession(chat.id) + }, ACCEPT_CLOSE_MS) } const respond = (item, decision) => run(decision === 'accept' ? 'Accepting' : 'Declining', async () => { await call('social/respond', { relationshipId: item.relationshipId, decision }) @@ -1781,8 +1821,13 @@ window.__ModuleLoader__.load({ me ? h('div', { className: 'fs-hint', 'data-flybot': 'my-handle', style: { padding: '0 10px' } }, `You are ${me}`) : null, h('div', { className: 'fs-label', 'data-flybot': 'accept-heading' }, 'Accept an invite'), h('form', { onSubmit: join, className: 'fs-card bt-social-join', 'data-flybot': 'accept-group' }, - // The paste box takes the full width on its own row, so the start of a link stays readable (#343). - h('input', { className: 'fs-input', style: { flex: '0 0 auto', width: '100%', boxSizing: 'border-box' }, value: code, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }), + // The paste box takes the full width on its own row and wraps, so a whole link is readable (#343, + // #499). Typing clears an error that no longer applies; Enter accepts, as in a one-line box. + h('textarea', { + className: 'fs-input fs-paste', rows: 2, style: { width: '100%' }, value: code, spellCheck: false, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', + onChange: (e) => { setCode(e.target.value); if (status?.tone === 'error') setStatus(null) }, + onKeyDown: (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent?.isComposing) { e.preventDefault(); e.currentTarget?.form?.requestSubmit?.() } }, + }), // Accepting grants only the invited Bot to you. The Bot you talk through is not shared, so // there is nothing to pick with one Bot (#228). mine.length > 1 ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as', title: "Talks for you. Not shared: your friend can't use it unless you invite them to it." }, @@ -1799,10 +1844,8 @@ window.__ModuleLoader__.load({ h('span', { className: 'fs-join-as-label' }, 'Share '), h('select', { className: 'fs-input', value: sharing, title: shareName, 'aria-label': 'Which of your Bots to share', 'data-flybot': 'share-bot', onChange: e => { setSharing(e.target.value); setMade(null) } }, mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))) : null, - // The button names the Bot the link shares; a long name ends in an ellipsis instead of being clipped. - h('div', { className: 'fs-line', 'data-flybot': 'invite-line' }, - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'mint-invite', title: `Copy invite link for ${shareName}`, style: { flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, disabled: busy, onClick: mint }, `Copy invite link for ${shareName}`)), - made ? h('input', { className: 'fs-input', readOnly: true, value: made.link, style: { flex: '0 0 auto', width: '100%', boxSizing: 'border-box' }, 'aria-label': `Invite link for ${made.botName}`, 'data-flybot': 'invite-link', onFocus: e => e.target.select?.(), onClick: e => e.target.select?.() }) : null), + // The same invite control as the Bot's Sharing (#499). + inviteControls({ botName: made?.botName || shareName, invite: made, busy, onMint: () => mint(true), onCopy: copyMade, onNew: () => mint(false) })), status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone, style: { padding: '2px 10px' } }, status.text) : null, incoming.length ? h('div', { className: 'bt-social-requests' }, h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))), @@ -1851,7 +1894,7 @@ window.__ModuleLoader__.load({ const ACCESS_POLL_MS = 5000 const ACCESS_NOTE = 'One row per person. Revoke anyone without affecting the rest. Default: nobody, Chief included.' - function ManageAccess({ bot }) { + function ManageAccess({ bot, onGrants }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') const [error, setError] = useState('') @@ -1871,6 +1914,9 @@ window.__ModuleLoader__.load({ timer?.unref?.() return () => clearInterval(timer) }, [bot?.id]) + // Sharing's hint mentions Revoke only once someone has access (#499). + const granted = (view?.grants ?? []).length + useEffect(() => { onGrants?.(granted) }, [granted, onGrants]) if (!bot?.id) return null const revoke = async (grant) => { setBusy(grant.id) @@ -1896,7 +1942,7 @@ window.__ModuleLoader__.load({ return h('div', { className: 'fs fs-card', 'data-flybot': 'manage-access' }, h('div', { className: 'fs-name' }, 'People with access'), h('div', { className: 'fs-hint fs-face-note' }, ACCESS_NOTE), - grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Invite a friend to grant it.') : null, + grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Copy an invite link above and send it to a friend.') : null, grants.map(grant => h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, h('div', { className: 'fs-col' }, h('span', { className: 'fs-name' }, grant.name || grant.grantee), @@ -1995,38 +2041,40 @@ window.__ModuleLoader__.load({ return h('div', { 'data-flybot': 'bot-social-details' }, h(FriendChatSettings, { bot, actions })) } - // The host draws the Sharing title (#406). + return h(OwnBotSharing, { bot }) + } + + // The host draws the Sharing title (#406). People with access tells the invite hint whether + // anyone has this Bot yet, so the hint mentions Revoke only then (#499). + function OwnBotSharing({ bot } = {}) { + const [granted, setGranted] = useState(0) return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, - h(InviteToBot, { bot }), - h(ManageAccess, { bot })) + h(InviteToBot, { bot, revocable: granted > 0 }), + h(ManageAccess, { bot, onGrants: setGranted })) } - // In a Bot's settings: invite a friend to this Bot. - function InviteToBot({ bot }) { + // In a Bot's settings: invite a friend to this Bot, with the same control as Add friend (#499). + function InviteToBot({ bot, revocable = false } = {}) { useStyles() const [invite, setInvite] = useState(null) const [note, setNote] = useState(null) const [busy, setBusy] = useState(false) if (!bot?.id) return null - const create = async () => { + const create = async (copy) => { setBusy(true); setNote(null) - try { setInvite(await call('social/create-invite', { botId: bot.id })) } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } + try { + const next = await call('social/create-invite', { botId: bot.id }) + setInvite(next) + if (copy) setNote(await copyInvite(next?.link, bot.name)) + } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } } // Called straight from the click, so the clipboard write keeps its permission. - const copy = text => { - const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined - if (clip?.writeText) clip.writeText(text).then(() => setNote({ tone: 'ok', text: 'Copied.' }), () => setNote({ tone: 'error', text: 'Could not copy. Select the link and copy it by hand.' })) - } - // The link first, then Copy link as the one primary action; a new link is a quiet text button (#230). + const copy = (text, withText) => { void copyInvite(text, bot.name, withText).then(setNote) } + // Revoke is mentioned once People with access has someone to revoke (#499). return h('div', { className: 'fs fs-card', 'data-flybot': 'invite-to-bot' }, - h('div', { className: 'fs-hint' }, `Invite grants ${bot.name} only. Revoke anytime in People with access.`), - invite ? null : h('button', { type: 'button', className: 'fs-btn', disabled: busy, onClick: create }, 'Invite to this Bot'), - invite ? h('input', { className: 'fs-input fs-invite-link', readOnly: true, value: invite.link, 'aria-label': 'Invite link', onFocus: e => e.target.select() }) : null, - invite ? h('div', { className: 'fs-line fs-invite-actions' }, - h('button', { type: 'button', className: 'fs-btn fs-btn-primary fs-btn-fit', 'data-flybot': 'copy-link', onClick: () => copy(invite.link) }, 'Copy link'), - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'copy-invite-text', onClick: () => copy(invite.text) }, 'Copy with instructions'), - h('button', { type: 'button', className: 'fs-link fs-invite-new', 'data-flybot': 'new-invite-link', disabled: busy, onClick: create }, 'New link')) : null, - note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) + h('div', { className: 'fs-hint', 'data-flybot': 'invite-hint-sharing' }, revocable ? `Invite grants ${bot.name} only. Revoke anytime in People with access.` : `Invite grants ${bot.name} only.`), + inviteControls({ botName: bot.name, invite, busy, onMint: () => create(true), onCopy: copy, onNew: () => create(false) }), + note ? h('div', { className: 'fs-status', role: 'status', 'data-tone': note.tone }, note.text) : null) } // A friend chat Bot Social paused (#404): a quiet notice, no buttons. There is no turn limit and diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62..4e2be115 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -873,9 +873,9 @@ test('invite card: "Copy link" copies the bare URL; "Copy with instructions" the }) // A client.js component tree with a scripted useState order. -function clientPlugin(initial = [], { call = async () => ({}) } = {}) { +function clientPlugin(initial = [], { call = async () => ({}), timers = { setTimeout, clearTimeout }, navigator = {} } = {}) { const registered = [] - const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, navigator: {}, URL } + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, console, navigator, URL } sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: () => () => {} }, call } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) @@ -5715,24 +5715,94 @@ test('#297: social/typing answers from Bot Social only when the server lists pre assert.equal(older.calls.some(c => c.name === 'list_presence'), false, 'an older server is never asked') }) -test('#343: accepting an invite keeps the panel open with the result; a join without a Bot says so', async () => { +test('#343 / #499: an accepted invite shows its result, then the panel closes on the new friend\'s Bot; a join without a Bot stays open', async () => { const lastStatus = setters => setters.filter(([index, value]) => index === 2 && value !== null).at(-1)?.[1] - for (const [reply, expected] of [ - [{ status: 'active', inviterName: 'Alice', inviterBotName: 'Helper' }, /^Alice shared Helper with you\.$/], - [{ status: 'active', inviterName: 'Alice' }, /^Joined Alice's invite, but their Bot hasn't loaded yet/], - [{ status: 'pending_target', inviterName: 'Alice' }, /^Request sent to Alice/], + for (const [reply, expected, closes] of [ + [{ status: 'active', inviter: '@helper.alice.home', inviterName: 'Alice', inviterBotName: 'Helper' }, /^Alice shared Helper with you\.$/, true], + [{ status: 'active', inviterName: 'Alice' }, /^Joined Alice's invite, but their Bot hasn't loaded yet/, false], + [{ status: 'pending_target', inviterName: 'Alice' }, /^Request sent to Alice/, false], ]) { + const pending = [] + const timers = { setTimeout: (fn, ms) => { pending.push([fn, ms]); return pending.length }, clearTimeout() {} } const { plugin, all, setters } = clientPlugin([null, 'https://s/i/INV1', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', ''], { call: async endpoint => (endpoint === 'social/join-invite' ? reply : {}), + timers, }) - const notes = [] + const opened = [] let closed = 0 - const nodes = all(plugin.AddFriend({ actions: { notify: text => notes.push(text) }, onDone: () => { closed += 1 } })) + const remoteChats = [{ id: 'chat-helper', name: 'Helper', remote: { handle: '@helper.alice.home' } }] + const nodes = all(plugin.AddFriend({ actions: { openSession: id => opened.push(id) }, remoteChats, onDone: () => { closed += 1 } })) await nodes.find(n => n.type === 'form').props.onSubmit({ preventDefault() {} }) - await new Promise(resolve => setTimeout(resolve, 10)) assert.match(lastStatus(setters)?.text ?? '', expected) - assert.equal(closed, 0, 'the panel stays open until Done') + assert.equal(closed, 0, 'the result shows first') + const close = pending.filter(([, ms]) => ms === 1800) + assert.equal(close.length, closes ? 1 : 0, 'only an accept that shared a Bot closes on its own') + for (const [fn] of close) fn() + assert.equal(closed, closes ? 1 : 0, 'no second click on Done') + assert.deepEqual(opened, closes ? ['chat-helper'] : [], 'the new friend\'s Bot opens') + } +}) + +test('#499: typing in the paste box clears a failed accept\'s error, not a result; Enter accepts', () => { + const failed = { tone: 'error', text: 'Accepting invite failed: This invite has expired' } + const { plugin, all, setters } = clientPlugin([null, 'https://s/i/OLD', failed, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', '']) + const paste = all(plugin.AddFriend({})).find(n => n.props?.['aria-label'] === 'Invite link or code') + paste.props.onChange({ target: { value: 'https://s/i/NEW' } }) + assert.deepEqual(setters, [[1, 'https://s/i/NEW'], [2, null]]) + const ok = clientPlugin([null, '', { tone: 'ok', text: 'Alice shared Helper with you.' }, false, [], '', '', '']) + ok.all(ok.plugin.AddFriend({})).find(n => n.props?.['aria-label'] === 'Invite link or code').props.onChange({ target: { value: 'x' } }) + assert.deepEqual(ok.setters, [[1, 'x']], 'a result stays until the next action') + let submitted = 0 + let prevented = 0 + paste.props.onKeyDown({ key: 'Enter', shiftKey: false, preventDefault: () => { prevented += 1 }, currentTarget: { form: { requestSubmit: () => { submitted += 1 } } } }) + assert.equal(submitted, 1) + assert.equal(prevented, 1, 'Enter accepts instead of adding a line') +}) + +test('#499: Add friend and Sharing use one invite control, and every copy note names the Bot', async () => { + const copied = [] + const navigator = { clipboard: { writeText: async (text) => { copied.push(text) } } } + const invite = { link: 'https://s/i/INV7', text: 'Add my bot … Link: https://s/i/INV7 (single use)' } + const mine = [{ id: 'a', name: 'Chief', isMain: true }] + const added = clientPlugin([null, '', null, false, mine, 'a', 'a', '', { ...invite, botName: 'Chief' }], { navigator }) + const friend = added.all(added.all(added.plugin.AddFriend({})).find(n => n.props?.['data-flybot'] === 'invite-group')) + const sharing = clientPlugin([invite, null, false], { navigator }) + const settings = sharing.all(sharing.plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' } })) + const control = (nodes, text) => nodes.filter(n => n.type === 'input' || n.type === 'button').map(n => (n.type === 'input' ? `link:${n.props['aria-label']}:${n.props.value}` : `${n.props['data-flybot']}:${text(n)}`)) + const expected = ['link:Invite link for Chief:https://s/i/INV7', 'copy-link:Copy link', 'copy-invite-text:Copy with instructions', 'new-invite-link:New link'] + assert.deepEqual(control(friend, added.text), expected) + assert.deepEqual(control(settings, sharing.text), expected) + friend.find(n => n.props?.['data-flybot'] === 'copy-link').props.onClick() + settings.find(n => n.props?.['data-flybot'] === 'copy-link').props.onClick() + settings.find(n => n.props?.['data-flybot'] === 'copy-invite-text').props.onClick() + assert.deepEqual(copied, [invite.link, invite.link, invite.text], 'written inside each click') + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(added.setters.filter(([index]) => index === 2).map(([, value]) => value.text), ['Invite link for Chief copied.']) + assert.deepEqual(sharing.setters.filter(([index]) => index === 1).map(([, value]) => value.text), ['Invite link for Chief copied.', 'Invite for Chief copied with instructions.']) + // Before a link exists, both offer the same one button that makes and copies it. + const fresh = clientPlugin([null, null, false], { navigator, call: async () => invite }) + const first = fresh.all(fresh.plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' } })) + const mint = first.find(n => n.props?.['data-flybot'] === 'mint-invite') + assert.equal(fresh.text(mint), 'Copy invite link for Chief') + await mint.props.onClick() + assert.deepEqual(fresh.setters.filter(([index]) => index === 1).map(([, value]) => value?.text ?? null), [null, 'Invite link for Chief copied.']) + const blank = clientPlugin([null, '', null, false, mine, 'a', 'a', '']) + assert.equal(blank.text(blank.all(blank.plugin.AddFriend({})).find(n => n.props?.['data-flybot'] === 'mint-invite')), 'Copy invite link for Chief') +}) + +test('#499: the Sharing hint mentions Revoke only once People with access has someone', () => { + const hint = (revocable) => { + const { plugin, all, text } = clientPlugin([null, null, false]) + return text(all(plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' }, revocable })).find(n => n.props?.['data-flybot'] === 'invite-hint-sharing')) } + assert.equal(hint(false), 'Invite grants Chief only.') + assert.equal(hint(true), 'Invite grants Chief only. Revoke anytime in People with access.') + const { plugin } = clientPlugin() + const own = plugin.BotSocialDetails({ bot: { id: 'a', name: 'Chief' } }) + const sharing = own.type(own.props) + const [invite, access] = sharing.props.children + assert.equal(invite.props.revocable, false, 'nobody has the Bot yet') + assert.equal(typeof access.props.onGrants, 'function', 'People with access reports how many have it') }) test('#343: a failed friend-list refresh does not replace the accepted-invite result; a long Bot name shrinks the button', async () => { @@ -5761,9 +5831,10 @@ test('#343: Add friend puts accepting first, on a full-width paste box, and name const form = nodes.findIndex(n => n.type === 'form') const invite = nodes.findIndex(n => n.props?.['data-flybot'] === 'invite-line') assert.ok(form > 0 && invite > form, 'the paste form comes before the invite line') - const paste = nodes.find(n => n.type === 'input' && n.props['aria-label'] === 'Invite link or code') + const paste = nodes.find(n => n.type === 'textarea' && n.props['aria-label'] === 'Invite link or code') assert.equal(paste.props.placeholder, 'Paste invite link or code') assert.equal(paste.props.style.width, '100%', 'the paste box has its own full-width row') + assert.match(paste.props.className, /\bfs-paste\b/, 'and wraps, so a whole pasted link shows (#499)') assert.equal(text(nodes.find(n => n.props?.['data-flybot'] === 'mint-invite')), 'Copy invite link for Chief') assert.equal(nodes.some(n => n.props?.['data-flybot'] === 'invite-link'), false, 'no link before one is made') })