diff --git a/changelog.d/3098-past-room-time-zh.fixed.md b/changelog.d/3098-past-room-time-zh.fixed.md new file mode 100644 index 000000000..55ff44ba4 --- /dev/null +++ b/changelog.d/3098-past-room-time-zh.fixed.md @@ -0,0 +1 @@ +In a Chinese window a past face-to-face room now reads its time in Chinese: the row's hover says `已结束 10月19日 14:05` instead of `Ended Oct 19 14:05`, a room that never ended says `开始`, and the title rebuilt from the room's members reads `面对面 · Alice · 10月19日 14:05`. The stored room name and the rules that parse it are untouched, so an English window shows exactly what it showed before ([#3098](https://github.com/ThinkFlowLab/FlyBot/issues/3098)). diff --git a/core/client.js b/core/client.js index b395f999b..34a7d1da8 100644 --- a/core/client.js +++ b/core/client.js @@ -240,6 +240,8 @@ var ZH = { "Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。", "Copy my handle": "复制我的 handle", "Your Bot Social handle": "你的 Bot Social handle", + // #P769b: a Past room's hover names when the call started, then the day and the clock. + Started: "开始", "Face-to-face": "面对面", "This Bot has no model set up yet.": "这个 Bot 还没有配置模型。", "This Bot could not answer right now.": "这个 Bot 现在没法回答。", diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 1173678b4..6cba8fab8 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -206,6 +206,8 @@ const ZH = { 'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。', 'Copy my handle': '复制我的 handle', 'Your Bot Social handle': '你的 Bot Social handle', + // #P769b: a Past room's hover names when the call started, then the day and the clock. + Started: '开始', 'Face-to-face': '面对面', 'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。', 'This Bot could not answer right now.': '这个 Bot 现在没法回答。', diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0c03df821..09bcb93d3 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`. 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/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), and the Past face-to-face row's hover words **Ended** / **Started** (#3097, #3098) — so a missing key here leaves that chip or that hover 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 12cfbc2eb..6f73df1f7 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -208,7 +208,7 @@ Settings → **Bot Social** lists friends and shares once the server is connecte ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): `faceWaitCode` and `shownFaceTitle` refuse a stored value that is not exactly six digits (`1234567` never reads as `123456`) and fall back to the code the owner typed, or to the creation time. -- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter ` left the room` is not also posted into the transcript. The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). A read-only window (the shell names it in `roster.readOnly`) keeps every read and drops every write (#2219): the section still polls `social/status` and still lists share requests, accept notices and past rooms, but draws no Dismiss, no Ask them to share a Bot, no Leave and no decision on a held send, offers none of the three Bot Social rows in the sidebar `+` menu, and `social/face-open` returns the stored past view (`readOnly: true`, name, status, members) instead of `upsertFace`-ing a local mirror, so reopening a past room creates no room; the room view's own head row draws the same chip-only head as a past room, with no Leave, no Auto-send switch and no invite (#2228), while a waiting room keeps its wait code and roster and drops its Leave and Enter chat; a local Bot's **Sharing** card draws the window's reason, no invite maker and no **Share with a friend**, and keeps **People with access** readable — the list of who has this Bot, and each person's Auto-send as a state line rather than a switch, with no Revoke — because `social/access` is a read the host allows (#2219, #2446), while a local group's **Members** card keeps no read of its own and draws only the reason with no **Create link** (#2418); a writable window behaves exactly as before. +- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter ` left the room` is not also posted into the transcript. The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. That day, that hover word and the first word of the title rebuilt from the row's members follow the interface language (#3098): a zh window draws `10月19日` and `已结束 10月19日 14:05` / `开始 …` / `面对面 · Alice · 10月19日 14:05` while an English window keeps the bytes it drew before. Only what the owner reads moves — the stored `room.name` (still `Face-to-face · Alice · Oct 19 14:05`) and every rule that parses it (`startsWith('Face-to-face · ')`, the two fallback matches, the six-digit code) stay English, because the next render parses that string again; a stored auto name's own English stamp is rewritten for display only, never saved back. Tests: `plugins/social/test/zh-face-time.test.js` (four zh cases, two English byte controls). The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). A read-only window (the shell names it in `roster.readOnly`) keeps every read and drops every write (#2219): the section still polls `social/status` and still lists share requests, accept notices and past rooms, but draws no Dismiss, no Ask them to share a Bot, no Leave and no decision on a held send, offers none of the three Bot Social rows in the sidebar `+` menu, and `social/face-open` returns the stored past view (`readOnly: true`, name, status, members) instead of `upsertFace`-ing a local mirror, so reopening a past room creates no room; the room view's own head row draws the same chip-only head as a past room, with no Leave, no Auto-send switch and no invite (#2228), while a waiting room keeps its wait code and roster and drops its Leave and Enter chat; a local Bot's **Sharing** card draws the window's reason, no invite maker and no **Share with a friend**, and keeps **People with access** readable — the list of who has this Bot, and each person's Auto-send as a state line rather than a switch, with no Revoke — because `social/access` is a read the host allows (#2219, #2446), while a local group's **Members** card keeps no read of its own and draws only the reason with no **Create link** (#2418); a writable window behaves exactly as before. - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). A number that is not empty and not exactly six digits keeps **Join** disabled and shows **Enter 6 digits** under the field (#1052). Six digits clear that line and enable Join once a Bot is picked; an empty field shows nothing. - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - **Open** on a Past room calls `social/face-open` (`plugins/social/src/social.js`): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come from `plugins/social/src/face-title.js`, with participant owners in stable handle order, so both homes show the same name (#172). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2166a9e09..1fec1c80e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -852,7 +852,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Content: '内容', 'Referenced {labels}': '引用了 {labels}', ', ': '、', -@@ -41,22 +41,79 @@ const ZH = { +@@ -41,22 +41,81 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -886,6 +886,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。', + 'Copy my handle': '复制我的 handle', + 'Your Bot Social handle': '你的 Bot Social handle', ++ // #P769b: a Past room's hover names when the call started, then the day and the clock. ++ Started: '开始', + 'Face-to-face': '面对面', + 'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。', + 'This Bot could not answer right now.': '这个 Bot 现在没法回答。', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 047e99859..de4876b70 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -160,7 +160,7 @@ diff --git a/client.js b/client.js Content: "内容", "Referenced {labels}": "引用了 {labels}", ", ": "、", -@@ -78,21 +78,78 @@ var ZH = { +@@ -78,21 +78,80 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -194,6 +194,8 @@ diff --git a/client.js b/client.js + "Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。", + "Copy my handle": "复制我的 handle", + "Your Bot Social handle": "你的 Bot Social handle", ++ // #P769b: a Past room's hover names when the call started, then the day and the clock. ++ Started: "开始", + "Face-to-face": "面对面", + "This Bot has no model set up yet.": "这个 Bot 还没有配置模型。", + "This Bot could not answer right now.": "这个 Bot 现在没法回答。", diff --git a/plugins/social/client.js b/plugins/social/client.js index c379480f9..b3a675d59 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1014,6 +1014,14 @@ window.__ModuleLoader__.load({ } return String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) } + // Which language the window is in, for the one thing a translator cannot carry: the order a date is + // written in. A plain script cannot import core's `language()`, so it asks the host translator for a + // word the zh dictionary surely holds; a host without one keeps English, which is `tr`'s fallback too. + const zhUi = () => { + const fn = globalThis.__flybot__?.t + if (typeof fn !== 'function') return false + try { return fn('Tasks') === '任务' } catch { return false } + } const host = (endpoint, payload) => { const fn = globalThis.__flybot__?.host if (typeof fn !== 'function') return Promise.reject(Object.assign(new Error('This FlyBot cannot make room links yet.'), { code: 'bot/not-found' })) @@ -1965,7 +1973,9 @@ window.__ModuleLoader__.load({ } // Past rooms read like the room's tab and sidebar row (#304): `Face-to-face · Alice, Bob · 482913`. // People come from every member when the row has them (a stored auto name can miss one), the - // number from the row or its stored title, else the time the room was created. + // number from the row or its stored title, else the time the room was created. Only the title the + // owner reads follows the window's language (#P769b); the rules above and the stored name itself + // stay English, because `named` is what the server saved and what the next render parses again. function shownFaceTitle(room) { const named = String(room?.name || '').trim() if (named && named !== 'Face-to-face room' && !named.startsWith('Face-to-face · ')) return named @@ -1984,24 +1994,39 @@ window.__ModuleLoader__.load({ const owners = rows.slice(0, 3).map(row => row.owner) const stored = named.match(/ · (\d{6})$/)?.[1] const code = sixRoomDigits(room?.joinCode || room?.code || stored) - const bits = ['Face-to-face'] + const bits = [tr('Face-to-face')] if (owners.length) bits.push(owners.join(', ')) const storedWhen = named.match(/ · ([A-Z][a-z]{2} \d{1,2} \d{2}:\d{2})$/)?.[1] if (code) bits.push(code) - else if (storedWhen) bits.push(storedWhen) + else if (storedWhen) bits.push(faceStamp(storedWhen)) else { // Created = the joining deadline minus the 10-minute window, never the deadline itself. const deadline = Date.parse(room?.expiresAt || '') const at = Date.parse(room?.createdAt || '') || (Number.isFinite(deadline) ? deadline - 10 * 60_000 : Date.parse(room?.startedAt || '')) const when = Number.isFinite(at) ? new Date(at) : new Date() - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - bits.push(`${months[when.getMonth()]} ${when.getDate()} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`) + bits.push(`${faceDay(when)} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`) } return bits.join(' · ') } // The time a Past row shows (#304): when the room ended here, else when it started (or was - // created). Today reads `14:05`, an earlier day `Oct 19`; the hover text has the full time. + // created). Today reads `14:05`, an earlier day `Oct 19` — `10月19日` in a zh window; the hover + // text names which of the two it is, in the window's language too (#P769b). const FACE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + function faceDayOf(month, day) { + return zhUi() ? `${month + 1}月${day}日` : `${FACE_MONTHS[month]} ${day}` + } + function faceDay(when) { + return faceDayOf(when.getMonth(), when.getDate()) + } + // A stored auto title carries its own English stamp (`Oct 19 14:05`). The rebuilt title shows it, + // so a zh window writes it the way `faceDay` does; the stored name is never rewritten, and an + // English window keeps the stamp's bytes exactly as they were saved. + function faceStamp(text) { + if (!zhUi()) return text + const [month, day, hhmm] = String(text).split(' ') + const index = FACE_MONTHS.indexOf(month) + return index < 0 || !day || !hhmm ? text : `${faceDayOf(index, day)} ${hhmm}` + } function faceRowTime(room, today = new Date()) { const ended = Date.parse(room?.endedAt || '') const deadline = Date.parse(room?.expiresAt || '') @@ -2010,9 +2035,9 @@ window.__ModuleLoader__.load({ if (!Number.isFinite(at)) return undefined const when = new Date(at) const hhmm = `${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}` - const day = `${FACE_MONTHS[when.getMonth()]} ${when.getDate()}` + const day = faceDay(when) const sameDay = when.getFullYear() === today.getFullYear() && when.getMonth() === today.getMonth() && when.getDate() === today.getDate() - return { text: sameDay ? hhmm : day, full: `${Number.isFinite(ended) ? 'Ended' : 'Started'} ${day} ${hhmm}` } + return { text: sameDay ? hhmm : day, full: `${tr(Number.isFinite(ended) ? 'Ended' : 'Started')} ${day} ${hhmm}` } } function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) { const [busy, setBusy] = useState('') diff --git a/plugins/social/test/zh-face-time.test.js b/plugins/social/test/zh-face-time.test.js new file mode 100644 index 000000000..d1c00ff35 --- /dev/null +++ b/plugins/social/test/zh-face-time.test.js @@ -0,0 +1,173 @@ +// #P769b: a Past room row's time read English in a zh window. The row itself says `14:05` today and +// `Oct 19` on an earlier day; hovering it says `Ended Oct 19 14:05`. Both the word (`Ended` / `Started`) +// and the month/day order were English text built at the draw site, so no dictionary entry could ever +// reach them — `text: …, full: `${…} ${day} ${hhmm}`` is the kind of template literal the copy scans +// cannot see. This mounts the real client.js with the tiny hook renderer the other client tests use +// (no jsdom), binds `__flybot__.t` to the zh dictionary, and reads the `face-time` node's own text and +// `title` out of a Past card. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { DICTIONARY } from '../../../core/src/client/i18n.js' + +const zh = DICTIONARY.zh +const live = { inst: null, idx: 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 }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, + useRef(value) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } + return inst.hooks[i] + }, +} +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() {} } + +function loadApp() { + const modules = new Map() + const window = { __ModuleLoader__: { load: ({ id, factory }) => modules.set(id, factory(name => (name === 'react' ? reactStub : {}))) } } + new Function('window', readFileSync(new URL('../client.js', import.meta.url), 'utf8'))(window) + return modules.get('@flybot/social') +} + +const fresh = () => ({ hooks: {}, effects: [] }) +function render(Component, inst) { + live.inst = inst + live.idx = 0 + inst.effects = [] + return Component(inst.props ?? {}) +} +// A component element is a function in `type`: it has to be called to see the card it draws. Its own +// hook slots are separate from the caller's, the way React keeps them apart. +function mount(element) { + const outer = { inst: live.inst, idx: live.idx } + live.inst = fresh() + live.idx = 0 + const out = element.type(element.props ?? {}) + live.inst = outer.inst + live.idx = outer.idx + return out +} +function walk(node, visit) { + if (node == null || node === false || node === true) return + if (Array.isArray(node)) { for (const child of node) walk(child, visit); return } + if (typeof node === 'string' || typeof node === 'number') return + visit(node) + if (typeof node.type === 'function') return walk(mount(node), visit) + return walk(node.children ?? [], visit) +} +const nodeOf = (tree, name) => { + let hit = null + walk(tree, node => { if (!hit && node?.props?.['data-flybot'] === name) hit = node }) + return hit +} +const nodeByClass = (tree, name) => { + let hit = null + walk(tree, node => { if (!hit && node?.props?.className === name) hit = node }) + return hit +} + +// The host's `t`: the dictionary entry when it has one, the English otherwise, which is what a +// Chinese window sees. +const hostT = (text, params) => { + const hit = Object.hasOwn(zh, text) ? zh[text] : text + return String(hit).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) +} +const englishT = (text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) + +// One local timestamp, so the day the row names does not depend on the runner's zone. +const local = (day, hh, mm) => new Date(2026, 9, day, hh, mm).toISOString() + +// A Past room's time: the row's own text, and the hover a reader gets on `face-time`. +const faceRowTime = (t, room) => { + globalThis.__flybot__ = { t, call: () => Promise.resolve({}) } + const app = loadApp() + const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Hall', members: [], ...room }, past: true } } + const node = nodeOf(render(app.FaceRoomCard, inst), 'face-time') + assert.ok(node, 'the Past row draws its time') + return { text: (node.children ?? []).filter(child => typeof child === 'string').join(''), title: node.props?.title } +} + +test('#P769b: a Past row’s hover names the end and the day in the window’s language', () => { + try { + const seen = faceRowTime(hostT, { endedAt: local(19, 14, 5) }) + assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`) + assert.equal(seen.title, `${zh.Ended} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`) + } finally { delete globalThis.__flybot__ } +}) + +test('#P769b: a room that never ended says when it started, in the window’s language', () => { + try { + const seen = faceRowTime(hostT, { startedAt: local(19, 14, 5) }) + assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`) + assert.equal(seen.title, `${zh.Started} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`) + } finally { delete globalThis.__flybot__ } +}) + +test('#P769b: today’s row keeps its bare clock and still hovers in the window’s language', () => { + try { + const now = new Date() + const seen = faceRowTime(hostT, { endedAt: now.toISOString() }) + assert.match(seen.text, /^\d\d:\d\d$/, `today’s row text: saw ${JSON.stringify(seen.text)}`) + assert.ok(seen.title.startsWith(`${zh.Ended} `), `the hover’s word: saw ${JSON.stringify(seen.title)}`) + assert.ok(/\d+月\d+日 {1,2}\d\d:\d\d$/.test(seen.title), `the hover’s day: saw ${JSON.stringify(seen.title)}`) + } finally { delete globalThis.__flybot__ } +}) + +test('#P769b: an English window keeps the row and hover bytes it drew before', () => { + try { + const ended = faceRowTime(englishT, { endedAt: local(19, 14, 5) }) + assert.equal(ended.text, 'Oct 19', `the English row day: saw ${JSON.stringify(ended.text)}`) + assert.equal(ended.title, 'Ended Oct 19 14:05', `the English hover: saw ${JSON.stringify(ended.title)}`) + const started = faceRowTime(englishT, { startedAt: local(19, 14, 5) }) + assert.equal(started.title, 'Started Oct 19 14:05', `the English hover: saw ${JSON.stringify(started.title)}`) + } finally { delete globalThis.__flybot__ } +}) + +// The row's own title is built the same way when the stored name carries no people: the word and the +// day are display text, while the `Face-to-face · …` parsing above them stays on the stored English. +const faceTitle = (t, room) => { + globalThis.__flybot__ = { t, call: () => Promise.resolve({}) } + const app = loadApp() + const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Face-to-face room', members: [{ handle: '@alice', ownerName: 'Alice' }], ...room }, past: true } } + const node = nodeByClass(render(app.FaceRoomCard, inst), 'fs-name') + assert.ok(node, 'the Past row draws its title') + return { title: node.props?.title, text: (node.children ?? []).filter(child => typeof child === 'string').join('') } +} + +test('#P769b: a Past row’s rebuilt title reads the window’s language too', () => { + try { + const built = faceTitle(hostT, { createdAt: local(19, 14, 5) }) + assert.equal(built.title, `面对面 · Alice · 10月19日 14:05`, `a rebuilt title: saw ${JSON.stringify(built.title)}`) + assert.equal(built.text, built.title, 'the row and its hover name the same room') + // A stored auto title carries its own English stamp; it is still display text here. + const stored = faceTitle(hostT, { name: 'Face-to-face · Oct 19 14:05' }) + assert.equal(stored.title, `面对面 · Alice · 10月19日 14:05`, `a stored stamp: saw ${JSON.stringify(stored.title)}`) + } finally { delete globalThis.__flybot__ } +}) + +test('#P769b: an English window keeps the rebuilt title bytes it drew before', () => { + try { + const built = faceTitle(englishT, { createdAt: local(19, 14, 5) }) + assert.equal(built.title, 'Face-to-face · Alice · Oct 19 14:05', `the English rebuilt title: saw ${JSON.stringify(built.title)}`) + const stored = faceTitle(englishT, { name: 'Face-to-face · Oct 19 14:05' }) + assert.equal(stored.title, 'Face-to-face · Alice · Oct 19 14:05', `the English stored title: saw ${JSON.stringify(stored.title)}`) + } finally { delete globalThis.__flybot__ } +})