From 6afd9d9961443a090046dcd14ce4e504a6dc03f5 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:45:59 +0800 Subject: [PATCH] build(core): rebuild client.js for the face chip's zh labels Generated by `npm --prefix core run build` from core/src/client, and `node scripts/ds-bot-patches.mjs generate` so core/ stays equal to ds-bot/ + patches/ds-bot/. The 0008 hunk only adds the four zh entries; it carries no other branch's content. --- changelog.d/3090-face-chip-i18n.changed.md | 1 + core/client.js | 4 + core/src/client/i18n.js | 4 + core/test/i18n-missing-entries.baseline.json | 3 - docs/subsystems/core-client.md | 2 +- docs/subsystems/social.md | 4 +- patches/ds-bot/0001-core-fixes.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 10 +- .../test/i18n-missing-entries.baseline.json | 3 - plugins/social/client.js | 11 +- plugins/social/test/face-chip-i18n.test.js | 135 ++++++++++++++++++ plugins/social/test/social.test.js | 2 +- 12 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 changelog.d/3090-face-chip-i18n.changed.md create mode 100644 plugins/social/test/face-chip-i18n.test.js diff --git a/changelog.d/3090-face-chip-i18n.changed.md b/changelog.d/3090-face-chip-i18n.changed.md new file mode 100644 index 000000000..71e11e4b6 --- /dev/null +++ b/changelog.d/3090-face-chip-i18n.changed.md @@ -0,0 +1 @@ +The face-to-face room status chip now reads in the window's language in every state — **Open**, **Closed**, **Left**, **Ended**, **Joining** — and an expired matching window says **Ended** instead of printing the internal `ended` status id (#3090). diff --git a/core/client.js b/core/client.js index 36e91ec44..b395f999b 100644 --- a/core/client.js +++ b/core/client.js @@ -1248,6 +1248,10 @@ var ZH = { "Source code": "源代码", "Release notes": "发布说明", Open: "打开", + Closed: "已关闭", + Left: "已离开", + Ended: "已结束", + Joining: "加入中", // Feedback and errors Feedback: "反馈", "Report a problem": "报告问题", diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index d5bdfa65e..1173678b4 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1202,6 +1202,10 @@ const ZH = { 'Source code': '源代码', 'Release notes': '发布说明', Open: '打开', + Closed: '已关闭', + Left: '已离开', + Ended: '已结束', + Joining: '加入中', // Feedback and errors Feedback: '反馈', 'Report a problem': '报告问题', diff --git a/core/test/i18n-missing-entries.baseline.json b/core/test/i18n-missing-entries.baseline.json index 8b2887e80..c947e0de6 100644 --- a/core/test/i18n-missing-entries.baseline.json +++ b/core/test/i18n-missing-entries.baseline.json @@ -14,7 +14,6 @@ "plugins/social/client.js:Bot Social could not look up that link right now. Try again.", "plugins/social/client.js:Chat paused", "plugins/social/client.js:Choose which of your Bots join.", - "plugins/social/client.js:Closed", "plugins/social/client.js:Copy with instructions", "plugins/social/client.js:Could not copy. Select the link and copy it by hand.#1", "plugins/social/client.js:Could not copy. Select the link and copy it by hand.#2", @@ -36,13 +35,11 @@ "plugins/social/client.js:Invites need a newer Bot Social. The 6-digit number still works.#2", "plugins/social/client.js:Invites need a newer Bot Social. The 6-digit number still works.#3", "plugins/social/client.js:Joined {}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.{}", - "plugins/social/client.js:Joining", "plugins/social/client.js:Joining with that link…#1", "plugins/social/client.js:Joining with that link…#2", "plugins/social/client.js:Leave this room?", "plugins/social/client.js:Leave#1", "plugins/social/client.js:Leave#2", - "plugins/social/client.js:Left", "plugins/social/client.js:Link ready", "plugins/social/client.js:Loading friends…", "plugins/social/client.js:Loading your Bots…", diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 778b392e1..0c03df821 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -37,7 +37,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/memory-empty.js` | The memory dialog's refused-folder line (#2101), decided by pure helpers because the suite has no renderer: `memoryEmptyNotice(view)` answers one line naming the folder when `entries` is empty and the host reported refused scopes in `view.blocked`, and `null` for the ordinary empty view, so `memory-panel.js` draws it above `{name} has not saved anything yet`; `memoryBlockedNotice(view)` answers that same line from `view.blocked` alone, whatever `entries` holds, and the panel draws it above the summary and above the entries list, because `entries` is own and team merged and one scope's entries would otherwise silence the other scope's refusal. A refused folder and a fresh Bot both answer `entries: []`; the host side of that field is in [core-host](core-host.md). Tests: `core/test/memory-empty.test.js`. | | `core/src/client/memory-panel.js` | The memory dialog. `MemoryRow` is the Bot details pane's row — how much it keeps and the way into `MemoryDialog` — and `useMemoryCount` keeps that count from the same read the dialog does, so a read the host refuses answers `memoryCountText`'s sentence with the host's reason instead of `Loading…` for as long as the window stays open, while an empty memory and an arrived count keep their own wording; that sentence is drawn a second time below the row in a `role="status"` note, because the row's `aria-label` replaces the text inside the button whenever the accessible name is computed (#2402). The dialog has two pages: the summary the model writes from the entries (stale and failed states, **Try again**), and the entries themselves behind the ⋯ menu, grouped by scope and topic with a two-click **Remove**. The foot's **Ask or update** is one model call that answers from the entries or changes them, and lists each change as applied or refused. `useMemoryView` re-reads every `POLL_MS` while a summary is being written and only the latest answer counts. Since #2276 a read-only window (`roster.readOnly` a non-empty string) draws neither **Remove** nor the ask form — the host refuses `memory-forget` and `memory-ask` — and says why in the host's own sentence, while the entries and **Regenerate summary** (a read) still work. Tests: `core/test/memory-panel-readonly.test.js`, `core/test/memory.test.js`, `core/test/memory-empty.test.js`, `core/test/memory-search.test.js`. | | `core/src/client/exchange.js` | The Bot-exchange sheet: `ExchangeDialog` polls the stored turns between two Bots and `ExchangeLine` draws one. A stored line is read once, by `exchangeText`: a leading `Chief → 助手` delegation trace and an `` fence are routing, not words, so the bubble and the message tools' **Copy** receive the same cleaned string. Unlike a sidebar row it is not folded to one line — a reply's paragraph breaks are the writer's own and stay exactly as stored. Since #2841 the trace is read the way every other surface reads it: `stripTrace(text, names)` in `src/client/text.js` removes a leading trace only when every hop is a roster Bot name, the names reach `readableLine`/`collapsedLine` and the bubble call sites (`cells.js`, `exchange.js`) as `botNames(roster)`, and with no roster nothing that merely looks like routing is removed — a reply that opens `Settings → Models 页面` keeps that line, and a real `Chief → FlyBot Dev-1: 正文` still reads `正文`. Tests: `core/test/exchange-read.test.js`, `core/test/inbound-ui.test.js`. | -| `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. | +| `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. The same map carries the words a plugin draws itself: the social plugin's face-room status chip calls `t()` for all five states — **Open** / **Closed** / **Left** / **Ended** / **Joining** (#3090) — so a missing key here leaves that chip in English. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | | `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). The shell ids differ by version — one `stats` footer in 0.2.0-rc.2, the counts split into an `activity` pill next to `usage` in 0.2.1-alpha.1 — so `statsDocks` registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); `usage` stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. | | `core/src/client/room-invite-preview.js` | The one room-invite preview card (#876). `invitePreviewOf` says only what a server reply can honestly say (a name cleaned at fixed point, the lead, `n / m in the room`, the deadline), `inviteCountdown` words the deadline, `RoomInvitePreview` draws the card from a caller-supplied `h`; `kind` renames the `data-flybot` hooks, so the face-room paste flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) render the same tree instead of two copies of the markup. Registered on `__flybot__` in `flybot-slots.js`, because a classic-script plugin cannot import it. Tests: `core/test/room-invite-preview.test.js`. | diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index ffce8e2bb..12cfbc2eb 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -132,11 +132,11 @@ Creating a group opens its Session with zero turns, and the host draws no sessio - Typing is the approval in a direct chat with a friend's Bot too (#227): `sendChat` (reached only from the owner's typed message in a remote chat) records the approval id Bot Social returns in `ownLineApprovals` and approves that hold at once, so no card, no "waits for your approval" reply and no wait badge; a failed approve is finished by the poll, which auto-approves only recorded ids. The call site and the recorded id are the marker, never the text: a Bot's message through `bot.send` keeps its card whatever it says. The friend's side shows the person when Bot Social vouches for it (next bullet); against a server without that, it still shows the signing Bot (e.g. `SelfB's Chief`), and an incoming `FLYBOT-FACE-HUMAN`-looking direct line stays the Bot's line: text is not an attestation. `pendingApprovals` / `social/decide-approval` call `flybot.chatApprovals.changed()` whenever the held set changes, so a friend chat's wait badge clears on Approve / Reject at once (#226). - Typed by the owner, attested by the server (#227): Bot Social stamps a message `typed_by_owner: true` only when an owner credential with the `attest_typed_by_owner` right (the FlyBot connector token; never a Bot token or a plain connector token) sends it with `typed_by_owner: true` — through `send_as_member` or `send_face_room_message`. The stamp is a top-level event field, covered by the platform countersignature, and absent when false; `GET /v1/platform` lists `typed_by_owner` under `features`. Sending: `sendChat` (the owner's typed line in a remote chat) and the face-room `onFaceLine` human fanout send the line **plain** with `typed_by_owner: true` instead of the `FLYBOT-FACE-HUMAN` envelope, but only when `serverAttests()` says the server stamps (`GET /v1/platform` features; the probe is shared — concurrent face polls await the one in-flight probe (#234) — an answered probe is held for 10 minutes, and one that got no answer, an unreachable or pre-platform server, is retried after a minute) and the token has not been refused (`typed_attestation_forbidden` → the plugin falls back to the old behaviour for that token, no retry loop). Bot-written lines never carry the flag (and `faceOutbound` still neutralizes the envelope marker in them). Receiving: `pollOnce` reads `event.typed_by_owner === true` and the peer's `owner_display_name` from the server's `from`, never from the text; a direct line then goes to `remote.chats.deliver({ human: true, ownerName })`, which puts `typedBy` on the message source, `turns.js` marks that turn (`state.remoteTypedTurns[session][turn]`, published as `roster.remoteTypedTurns`), and `AssistantCell` draws a marked turn as the person (name + person mark, `PersonMessage`) instead of the Bot. A stamped line to one of my own Bots that is not in a chat with that friend's Bot (a friend typing to my Helper through their Chief) goes to `flybot.bot.send` with `from.typedBy` (the server's `owner_display_name`; the host copies it, cleaned, to the message source's `remote.typedBy`), and the chat draws it as the person (`RemoteMessage`: name, person mark, `via `), not as the friend's Bot; an unstamped line from the same Bot stays a Bot line (#233 follow-up). A face-room `room.message` with the stamp renders as the person (`renderFaceEvent(event, { attested })`, `rooms.postHuman`, the name from the authenticated `from`). On a server that stamps, an unstamped `FLYBOT-FACE-HUMAN` envelope is **not** a person's line: it is the Bot's line with the marker unwrapped to plain text, so a Bot cannot pass as the owner by text (#118, #184, #187 still hold). On a server that does not stamp (old deployment), the legacy envelope path is unchanged. Tests: `plugins/social/test/social.test.js` (`typed_by_owner`), `core/test/remote-chat.test.js`, `core/test/turn-tail.test.js`. - Friend Bot details overview is Auto-send plus a muted **Remove this Bot** link (not a primary button). The DSH settings-shell chrome around empty local panes is unchanged. The host opens that overview via the compact remote Bot settings sheet (`remote-bot-settings`); the sidebar gear is a sibling of the friend's `bt-row`, not nested inside it (#166). The Auto-send box holds that setting only, and Remove this Bot sits in its own row below it; the sheet takes focus when it opens and closes on Escape, and inside a tree item the row with its gear shrinks to fit (`.bt-tree-item>.bt-row-wrap`), so focusing the gear never scrolls the sidebar sideways (#274). -- Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). +- Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **Open**; expired matching windows under **Past rooms** read **Ended** (the word, not the internal `ended` id it used to print); candidates show a countdown (`12m` / `45s`); closed and left read **Closed** / **Left** (#166). All five words go through the interface dictionary (#3090). - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). - QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (`data-flybot=face-qr`, one `path` in an `svg`), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload is `FLYBOT-` plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser's `BarcodeDetector` and the camera with `getUserMedia`; a browser without the decoder says so **before** it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else is `That is not a FlyBot room code. Ask for the 6-digit number instead.` Typing the number always stays, and a successful scan ends in the same `social/face-join` the number field uses — it only fills the field. - Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). Since #876 that card is not markup of this plugin's own: `RoomInvitePreview` (`core/src/client/room-invite-preview.js`, registered on `__flybot__` because a classic-script plugin cannot import it) draws it from the same `invitePreviewOf` / `inviteCountdown` rules, and `kind` renames only the `data-flybot` hooks (`face-link-preview` here, `room-invite-preview` in the member-invite dialog), so the two surfaces render one tree instead of two copies. The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. -- 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). +- Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** / **Left** / **Ended** / **Joining** in sentence case, in the window's language, with readable contrast; the past row keeps its own spacing between avatars, title, time and chip (#170, #3090). - 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. - On a phone-width screen (below 640px, #362) the hold card's **Approve** / **Reject** are 40px touch targets at the right of their row; wider screens keep the compact 28px buttons. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index a83ad79dc..2166a9e09 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1495,7 +1495,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Updates: '更新', 'Check for updates': '检查更新', 'Checking…': '正在检查…', -@@ -711,6 +711,13 @@ const ZH = { +@@ -711,10 +711,21 @@ const ZH = { 'Update checks are off.': '已关闭更新检查。', 'Installed from a local folder; update it there.': '从本地文件夹安装,请在那里更新。', 'Updates are not offered for this install.': '这种安装方式不提供更新。', @@ -1509,6 +1509,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Links: '链接', 'Source code': '源代码', 'Release notes': '发布说明', + Open: '打开', ++ Closed: '已关闭', ++ Left: '已离开', ++ Ended: '已结束', ++ Joining: '加入中', + // Feedback and errors + Feedback: '反馈', + 'Report a problem': '报告问题', @@ -726,7 +726,7 @@ const ZH = { Show: '查看', Hide: '收起', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f83bbc6db..047e99859 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -799,7 +799,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ var ZH = { +@@ -761,10 +761,21 @@ var ZH = { "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -813,6 +813,14 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", + Open: "打开", ++ Closed: "已关闭", ++ Left: "已离开", ++ Ended: "已结束", ++ Joining: "加入中", + // Feedback and errors + Feedback: "反馈", + "Report a problem": "报告问题", @@ -776,7 +776,7 @@ var ZH = { Show: "查看", Hide: "收起", diff --git a/patches/ds-bot/files/test/i18n-missing-entries.baseline.json b/patches/ds-bot/files/test/i18n-missing-entries.baseline.json index 8b2887e80..c947e0de6 100644 --- a/patches/ds-bot/files/test/i18n-missing-entries.baseline.json +++ b/patches/ds-bot/files/test/i18n-missing-entries.baseline.json @@ -14,7 +14,6 @@ "plugins/social/client.js:Bot Social could not look up that link right now. Try again.", "plugins/social/client.js:Chat paused", "plugins/social/client.js:Choose which of your Bots join.", - "plugins/social/client.js:Closed", "plugins/social/client.js:Copy with instructions", "plugins/social/client.js:Could not copy. Select the link and copy it by hand.#1", "plugins/social/client.js:Could not copy. Select the link and copy it by hand.#2", @@ -36,13 +35,11 @@ "plugins/social/client.js:Invites need a newer Bot Social. The 6-digit number still works.#2", "plugins/social/client.js:Invites need a newer Bot Social. The 6-digit number still works.#3", "plugins/social/client.js:Joined {}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.{}", - "plugins/social/client.js:Joining", "plugins/social/client.js:Joining with that link…#1", "plugins/social/client.js:Joining with that link…#2", "plugins/social/client.js:Leave this room?", "plugins/social/client.js:Leave#1", "plugins/social/client.js:Leave#2", - "plugins/social/client.js:Left", "plugins/social/client.js:Link ready", "plugins/social/client.js:Loading friends…", "plugins/social/client.js:Loading your Bots…", diff --git a/plugins/social/client.js b/plugins/social/client.js index a7144b328..c379480f9 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1953,11 +1953,14 @@ window.__ModuleLoader__.load({ } function faceChipLabel(room) { const chip = faceChip(room) + // #3090: every state this chip can show goes through the translator with the English word as its + // key, so a window in another language does not keep four of the five in English. `ended` is the + // internal status id; the word this chip shows is `Ended`. if (chip === 'open') return tr('Open') - if (chip === 'closed') return 'Closed' - if (chip === 'left') return 'Left' - if (chip === 'ended') return 'ended' - if (chip === 'joining') return 'Joining' + if (chip === 'closed') return tr('Closed') + if (chip === 'left') return tr('Left') + if (chip === 'ended') return tr('Ended') + if (chip === 'joining') return tr('Joining') return chip } // Past rooms read like the room's tab and sidebar row (#304): `Face-to-face · Alice, Bob · 482913`. diff --git a/plugins/social/test/face-chip-i18n.test.js b/plugins/social/test/face-chip-i18n.test.js new file mode 100644 index 000000000..e345f8cee --- /dev/null +++ b/plugins/social/test/face-chip-i18n.test.js @@ -0,0 +1,135 @@ +// #3090: the face-to-face room chip says "Open" through the translator and four of its five +// states do not — `Closed`, `Left`, `ended` (lowercase, and a status id rather than a word) and +// `Joining` are bare literals, so a window whose language is not English keeps drawing them in +// English. This pins every branch of `faceChipLabel` to the interface language: the stub below +// answers only for the English key it was given, so a label that never reaches it, or reaches it +// spelled differently, stays English and fails. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const live = { inst: null, idx: 0, rIdx: 0, eIdx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useRef(init) { + const { inst } = live + const i = live.rIdx++ + if (!(i in inst.refs)) inst.refs[i] = { current: init } + return inst.refs[i] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.eIdx++ + inst.effects.push([i, fn, deps]) + }, +} +for (const name of ['useMemo', 'useCallback', 'useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value + +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +const fresh = () => ({ hooks: {}, refs: {}, effects: [], dirty: false }) +function render(Component, inst, props = inst.props) { + live.inst = inst + live.idx = 0 + live.rIdx = 0 + live.eIdx = 0 + inst.effects = [] + inst.props = props + inst.dirty = false + return Component(props) +} +function mount(element) { + const outer = { inst: live.inst, idx: live.idx, rIdx: live.rIdx, eIdx: live.eIdx } + live.inst = fresh() + live.idx = 0 + live.rIdx = 0 + live.eIdx = 0 + const out = element.type(element.props ?? {}) + live.inst = outer.inst + live.idx = outer.idx + live.rIdx = outer.rIdx + live.eIdx = outer.eIdx + return out +} +function find(node, predicate) { + if (Array.isArray(node)) { + for (const child of node) { + const hit = find(child, predicate) + if (hit) return hit + } + return undefined + } + if (!node || typeof node !== 'object') return undefined + if (predicate(node)) return node + if (typeof node.type === 'function') return find(mount(node), predicate) + return find(node.children, predicate) +} +const byFlybot = id => node => node.props?.['data-flybot'] === id +const chipText = node => (node.children ?? []).map(child => (typeof child === 'string' ? child : '')).join('') + +// The language a window runs in: `t` answers for the English key it is handed and echoes anything +// else, which is what the host's own translator does for a key the dictionary does not carry. +const ZH = { Open: '打开', Closed: '已关闭', Left: '已离开', Ended: '已结束', Joining: '加入中' } + +function loadApp() { + const modules = new Map() + const window = { __ModuleLoader__: { load: ({ id, factory }) => modules.set(id, factory(name => (name === 'react' ? reactStub : {}))) } } + const file = process.env.FACE_CHIP_CLIENT ?? '../client.js' + new Function('window', readFileSync(new URL(file, import.meta.url), 'utf8'))(window) + return modules.get('@flybot/social') +} + +// A Past row is the read-only chip surface: no leave button, no room to open, just the chip. +function chipOf(app, room) { + const inst = fresh() + const tree = render(app.FaceRoomCard, inst, { + room: { id: 'r1', faceRoomId: 'frm1', members: [], ...room }, + actions: {}, onChange: () => {}, roster: { bots: [], byId: {} }, remoteChats: [], past: true, + }) + const chip = find(tree, byFlybot('face-status')) + assert.ok(chip, 'the room row draws a status chip') + return chipText(chip) +} + +function withLanguage(fn) { + globalThis.__flybot__ = { t: (text) => ZH[text] ?? String(text) } + try { return fn() } finally { delete globalThis.__flybot__ } +} + +test('#3090: an open face-to-face room says Open in the interface language', () => { + const app = loadApp() + withLanguage(() => assert.equal(chipOf(app, { status: 'open' }), '打开')) +}) + +test('#3090: a closed face-to-face room says Closed in the interface language', () => { + const app = loadApp() + withLanguage(() => assert.equal(chipOf(app, { status: 'closed' }), '已关闭')) + // A dictionary without the key falls back to the English word, not to a guessed translation. + globalThis.__flybot__ = { t: text => String(text) } + try { assert.equal(chipOf(app, { status: 'closed' }), 'Closed') } finally { delete globalThis.__flybot__ } +}) + +test('#3090: a left face-to-face room says Left in the interface language', () => { + const app = loadApp() + withLanguage(() => assert.equal(chipOf(app, { status: 'left' }), '已离开')) +}) + +test('#3090: an ended face-to-face room says Ended, not a lowercase status id', () => { + const app = loadApp() + withLanguage(() => assert.equal(chipOf(app, { status: 'candidate', expiresAt: new Date(Date.now() - 60_000).toISOString() }), '已结束')) +}) + +test('#3090: a room still joining says Joining in the interface language', () => { + const app = loadApp() + // A candidate without a deadline is the joining state: no countdown to show yet. + withLanguage(() => assert.equal(chipOf(app, { status: 'candidate' }), '加入中')) +}) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index e6db9001a..8e0db2ca8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -4371,7 +4371,7 @@ test('an ended matching window sits under Past rooms and opens from its row, not const tree = walk(pluginOf([rooms, true]).FaceRooms({})) const ended = tree.find(n => n.props['data-flybot'] === 'face-room' && n.props['data-status'] === 'candidate') assert.ok(ended?.props['data-past']) - assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('ended'))) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('Ended'))) assert.ok(tree.some(n => n.props.className === 'fs-face-row' && n.props.role === 'button')) assert.equal(tree.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 0) })