Skip to content

Commit bdcaed1

Browse files
committed
fix(social): read the face-card time in the interface language (#3098)
A Past face-to-face card drew three things in English inside a zh window: the day (`Oct 19`), the hover prefix (`Ended` / `Started`), and the first word of the title rebuilt from the room's members (`Face-to-face · Alice · Oct 19 14:05`). The day now goes through `faceDay` (`10月19日`), the hover prefix through the dictionary (`Started: '开始'`; `Ended: '已结束'` came with #3097), and the rebuilt title's first word through `tr('Face-to-face')`. A stored auto name's own English stamp is rewritten only in a zh window. The parsing side does not move: the two fallback regexes, the `named.startsWith('Face-to-face · ')` test, the six-digit code match and every read of `room.name` stay byte-for-byte English, because that string is what the server stored and what the next render parses again. An English window renders the bytes it drew before. Docs-not-needed: copy and date order in a Past row; no architecture changed.
1 parent 3431164 commit bdcaed1

7 files changed

Lines changed: 217 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)).

‎core/client.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ var ZH = {
240240
"Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。",
241241
"Copy my handle": "复制我的 handle",
242242
"Your Bot Social handle": "你的 Bot Social handle",
243+
// #P769b: a Past room's hover names when the call started, then the day and the clock.
244+
Started: "开始",
243245
"Face-to-face": "面对面",
244246
"This Bot has no model set up yet.": "这个 Bot 还没有配置模型。",
245247
"This Bot could not answer right now.": "这个 Bot 现在没法回答。",

‎core/src/client/i18n.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ const ZH = {
206206
'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。',
207207
'Copy my handle': '复制我的 handle',
208208
'Your Bot Social handle': '你的 Bot Social handle',
209+
// #P769b: a Past room's hover names when the call started, then the day and the clock.
210+
Started: '开始',
209211
'Face-to-face': '面对面',
210212
'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。',
211213
'This Bot could not answer right now.': '这个 Bot 现在没法回答。',

‎patches/ds-bot/0001-core-fixes.patch‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
852852
Content: '内容',
853853
'Referenced {labels}': '引用了 {labels}',
854854
', ': '、',
855-
@@ -41,22 +41,79 @@ const ZH = {
855+
@@ -41,22 +41,81 @@ const ZH = {
856856
' and ': '、',
857857
and: '和',
858858
'{count} members': '{count} 位成员',
@@ -886,6 +886,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
886886
+ 'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。',
887887
+ 'Copy my handle': '复制我的 handle',
888888
+ 'Your Bot Social handle': '你的 Bot Social handle',
889+
+ // #P769b: a Past room's hover names when the call started, then the day and the clock.
890+
+ Started: '开始',
889891
+ 'Face-to-face': '面对面',
890892
+ 'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。',
891893
+ 'This Bot could not answer right now.': '这个 Bot 现在没法回答。',

‎patches/ds-bot/0008-client-bundle.patch‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ diff --git a/client.js b/client.js
160160
Content: "内容",
161161
"Referenced {labels}": "引用了 {labels}",
162162
", ": "、",
163-
@@ -78,21 +78,78 @@ var ZH = {
163+
@@ -78,21 +78,80 @@ var ZH = {
164164
" and ": "、",
165165
and: "和",
166166
"{count} members": "{count} 位成员",
@@ -194,6 +194,8 @@ diff --git a/client.js b/client.js
194194
+ "Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。",
195195
+ "Copy my handle": "复制我的 handle",
196196
+ "Your Bot Social handle": "你的 Bot Social handle",
197+
+ // #P769b: a Past room's hover names when the call started, then the day and the clock.
198+
+ Started: "开始",
197199
+ "Face-to-face": "面对面",
198200
+ "This Bot has no model set up yet.": "这个 Bot 还没有配置模型。",
199201
+ "This Bot could not answer right now.": "这个 Bot 现在没法回答。",

‎plugins/social/client.js‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,14 @@ window.__ModuleLoader__.load({
10141014
}
10151015
return String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? ''))
10161016
}
1017+
// Which language the window is in, for the one thing a translator cannot carry: the order a date is
1018+
// written in. A plain script cannot import core's `language()`, so it asks the host translator for a
1019+
// word the zh dictionary surely holds; a host without one keeps English, which is `tr`'s fallback too.
1020+
const zhUi = () => {
1021+
const fn = globalThis.__flybot__?.t
1022+
if (typeof fn !== 'function') return false
1023+
try { return fn('Tasks') === '任务' } catch { return false }
1024+
}
10171025
const host = (endpoint, payload) => {
10181026
const fn = globalThis.__flybot__?.host
10191027
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({
19651973
}
19661974
// Past rooms read like the room's tab and sidebar row (#304): `Face-to-face · Alice, Bob · 482913`.
19671975
// People come from every member when the row has them (a stored auto name can miss one), the
1968-
// number from the row or its stored title, else the time the room was created.
1976+
// number from the row or its stored title, else the time the room was created. Only the title the
1977+
// owner reads follows the window's language (#P769b); the rules above and the stored name itself
1978+
// stay English, because `named` is what the server saved and what the next render parses again.
19691979
function shownFaceTitle(room) {
19701980
const named = String(room?.name || '').trim()
19711981
if (named && named !== 'Face-to-face room' && !named.startsWith('Face-to-face · ')) return named
@@ -1984,24 +1994,39 @@ window.__ModuleLoader__.load({
19841994
const owners = rows.slice(0, 3).map(row => row.owner)
19851995
const stored = named.match(/ · (\d{6})$/)?.[1]
19861996
const code = sixRoomDigits(room?.joinCode || room?.code || stored)
1987-
const bits = ['Face-to-face']
1997+
const bits = [tr('Face-to-face')]
19881998
if (owners.length) bits.push(owners.join(', '))
19891999
const storedWhen = named.match(/ · ([A-Z][a-z]{2} \d{1,2} \d{2}:\d{2})$/)?.[1]
19902000
if (code) bits.push(code)
1991-
else if (storedWhen) bits.push(storedWhen)
2001+
else if (storedWhen) bits.push(faceStamp(storedWhen))
19922002
else {
19932003
// Created = the joining deadline minus the 10-minute window, never the deadline itself.
19942004
const deadline = Date.parse(room?.expiresAt || '')
19952005
const at = Date.parse(room?.createdAt || '') || (Number.isFinite(deadline) ? deadline - 10 * 60_000 : Date.parse(room?.startedAt || ''))
19962006
const when = Number.isFinite(at) ? new Date(at) : new Date()
1997-
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
1998-
bits.push(`${months[when.getMonth()]} ${when.getDate()} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`)
2007+
bits.push(`${faceDay(when)} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`)
19992008
}
20002009
return bits.join(' · ')
20012010
}
20022011
// The time a Past row shows (#304): when the room ended here, else when it started (or was
2003-
// created). Today reads `14:05`, an earlier day `Oct 19`; the hover text has the full time.
2012+
// created). Today reads `14:05`, an earlier day `Oct 19` — `10月19日` in a zh window; the hover
2013+
// text names which of the two it is, in the window's language too (#P769b).
20042014
const FACE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
2015+
function faceDayOf(month, day) {
2016+
return zhUi() ? `${month + 1}月${day}日` : `${FACE_MONTHS[month]} ${day}`
2017+
}
2018+
function faceDay(when) {
2019+
return faceDayOf(when.getMonth(), when.getDate())
2020+
}
2021+
// A stored auto title carries its own English stamp (`Oct 19 14:05`). The rebuilt title shows it,
2022+
// so a zh window writes it the way `faceDay` does; the stored name is never rewritten, and an
2023+
// English window keeps the stamp's bytes exactly as they were saved.
2024+
function faceStamp(text) {
2025+
if (!zhUi()) return text
2026+
const [month, day, hhmm] = String(text).split(' ')
2027+
const index = FACE_MONTHS.indexOf(month)
2028+
return index < 0 || !day || !hhmm ? text : `${faceDayOf(index, day)} ${hhmm}`
2029+
}
20052030
function faceRowTime(room, today = new Date()) {
20062031
const ended = Date.parse(room?.endedAt || '')
20072032
const deadline = Date.parse(room?.expiresAt || '')
@@ -2010,9 +2035,9 @@ window.__ModuleLoader__.load({
20102035
if (!Number.isFinite(at)) return undefined
20112036
const when = new Date(at)
20122037
const hhmm = `${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`
2013-
const day = `${FACE_MONTHS[when.getMonth()]} ${when.getDate()}`
2038+
const day = faceDay(when)
20142039
const sameDay = when.getFullYear() === today.getFullYear() && when.getMonth() === today.getMonth() && when.getDate() === today.getDate()
2015-
return { text: sameDay ? hhmm : day, full: `${Number.isFinite(ended) ? 'Ended' : 'Started'} ${day} ${hhmm}` }
2040+
return { text: sameDay ? hhmm : day, full: `${tr(Number.isFinite(ended) ? 'Ended' : 'Started')} ${day} ${hhmm}` }
20162041
}
20172042
function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) {
20182043
const [busy, setBusy] = useState('')
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// #P769b: a Past room row's time read English in a zh window. The row itself says `14:05` today and
2+
// `Oct 19` on an earlier day; hovering it says `Ended Oct 19 14:05`. Both the word (`Ended` / `Started`)
3+
// and the month/day order were English text built at the draw site, so no dictionary entry could ever
4+
// reach them — `text: …, full: `${…} ${day} ${hhmm}`` is the kind of template literal the copy scans
5+
// cannot see. This mounts the real client.js with the tiny hook renderer the other client tests use
6+
// (no jsdom), binds `__flybot__.t` to the zh dictionary, and reads the `face-time` node's own text and
7+
// `title` out of a Past card.
8+
import assert from 'node:assert/strict'
9+
import { readFileSync } from 'node:fs'
10+
import { test } from 'node:test'
11+
import { DICTIONARY } from '../../../core/src/client/i18n.js'
12+
13+
const zh = DICTIONARY.zh
14+
const live = { inst: null, idx: 0 }
15+
const reactStub = {
16+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
17+
Fragment: 'F',
18+
Component: class {},
19+
useState(init) {
20+
const { inst } = live
21+
const i = live.idx++
22+
if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init }
23+
const slot = inst.hooks[i]
24+
return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }]
25+
},
26+
useEffect(fn, deps) {
27+
const { inst } = live
28+
const i = live.idx++
29+
if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined }
30+
inst.effects.push([i, fn, deps])
31+
},
32+
useRef(value) {
33+
const { inst } = live
34+
const i = live.idx++
35+
if (!(i in inst.hooks)) inst.hooks[i] = { current: value }
36+
return inst.hooks[i]
37+
},
38+
}
39+
for (const name of ['useMemo', 'useCallback', 'useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value
40+
41+
globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} }
42+
43+
function loadApp() {
44+
const modules = new Map()
45+
const window = { __ModuleLoader__: { load: ({ id, factory }) => modules.set(id, factory(name => (name === 'react' ? reactStub : {}))) } }
46+
new Function('window', readFileSync(new URL('../client.js', import.meta.url), 'utf8'))(window)
47+
return modules.get('@flybot/social')
48+
}
49+
50+
const fresh = () => ({ hooks: {}, effects: [] })
51+
function render(Component, inst) {
52+
live.inst = inst
53+
live.idx = 0
54+
inst.effects = []
55+
return Component(inst.props ?? {})
56+
}
57+
// A component element is a function in `type`: it has to be called to see the card it draws. Its own
58+
// hook slots are separate from the caller's, the way React keeps them apart.
59+
function mount(element) {
60+
const outer = { inst: live.inst, idx: live.idx }
61+
live.inst = fresh()
62+
live.idx = 0
63+
const out = element.type(element.props ?? {})
64+
live.inst = outer.inst
65+
live.idx = outer.idx
66+
return out
67+
}
68+
function walk(node, visit) {
69+
if (node == null || node === false || node === true) return
70+
if (Array.isArray(node)) { for (const child of node) walk(child, visit); return }
71+
if (typeof node === 'string' || typeof node === 'number') return
72+
visit(node)
73+
if (typeof node.type === 'function') return walk(mount(node), visit)
74+
return walk(node.children ?? [], visit)
75+
}
76+
const nodeOf = (tree, name) => {
77+
let hit = null
78+
walk(tree, node => { if (!hit && node?.props?.['data-flybot'] === name) hit = node })
79+
return hit
80+
}
81+
const nodeByClass = (tree, name) => {
82+
let hit = null
83+
walk(tree, node => { if (!hit && node?.props?.className === name) hit = node })
84+
return hit
85+
}
86+
87+
// The host's `t`: the dictionary entry when it has one, the English otherwise, which is what a
88+
// Chinese window sees.
89+
const hostT = (text, params) => {
90+
const hit = Object.hasOwn(zh, text) ? zh[text] : text
91+
return String(hit).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? ''))
92+
}
93+
const englishT = (text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? ''))
94+
95+
// One local timestamp, so the day the row names does not depend on the runner's zone.
96+
const local = (day, hh, mm) => new Date(2026, 9, day, hh, mm).toISOString()
97+
98+
// A Past room's time: the row's own text, and the hover a reader gets on `face-time`.
99+
const faceRowTime = (t, room) => {
100+
globalThis.__flybot__ = { t, call: () => Promise.resolve({}) }
101+
const app = loadApp()
102+
const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Hall', members: [], ...room }, past: true } }
103+
const node = nodeOf(render(app.FaceRoomCard, inst), 'face-time')
104+
assert.ok(node, 'the Past row draws its time')
105+
return { text: (node.children ?? []).filter(child => typeof child === 'string').join(''), title: node.props?.title }
106+
}
107+
108+
test('#P769b: a Past row’s hover names the end and the day in the window’s language', () => {
109+
try {
110+
const seen = faceRowTime(hostT, { endedAt: local(19, 14, 5) })
111+
assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`)
112+
assert.equal(seen.title, `${zh.Ended} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`)
113+
} finally { delete globalThis.__flybot__ }
114+
})
115+
116+
test('#P769b: a room that never ended says when it started, in the window’s language', () => {
117+
try {
118+
const seen = faceRowTime(hostT, { startedAt: local(19, 14, 5) })
119+
assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`)
120+
assert.equal(seen.title, `${zh.Started} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`)
121+
} finally { delete globalThis.__flybot__ }
122+
})
123+
124+
test('#P769b: today’s row keeps its bare clock and still hovers in the window’s language', () => {
125+
try {
126+
const now = new Date()
127+
const seen = faceRowTime(hostT, { endedAt: now.toISOString() })
128+
assert.match(seen.text, /^\d\d:\d\d$/, `today’s row text: saw ${JSON.stringify(seen.text)}`)
129+
assert.ok(seen.title.startsWith(`${zh.Ended} `), `the hover’s word: saw ${JSON.stringify(seen.title)}`)
130+
assert.ok(/\d+月\d+日 {1,2}\d\d:\d\d$/.test(seen.title), `the hover’s day: saw ${JSON.stringify(seen.title)}`)
131+
} finally { delete globalThis.__flybot__ }
132+
})
133+
134+
test('#P769b: an English window keeps the row and hover bytes it drew before', () => {
135+
try {
136+
const ended = faceRowTime(englishT, { endedAt: local(19, 14, 5) })
137+
assert.equal(ended.text, 'Oct 19', `the English row day: saw ${JSON.stringify(ended.text)}`)
138+
assert.equal(ended.title, 'Ended Oct 19 14:05', `the English hover: saw ${JSON.stringify(ended.title)}`)
139+
const started = faceRowTime(englishT, { startedAt: local(19, 14, 5) })
140+
assert.equal(started.title, 'Started Oct 19 14:05', `the English hover: saw ${JSON.stringify(started.title)}`)
141+
} finally { delete globalThis.__flybot__ }
142+
})
143+
144+
// The row's own title is built the same way when the stored name carries no people: the word and the
145+
// day are display text, while the `Face-to-face · …` parsing above them stays on the stored English.
146+
const faceTitle = (t, room) => {
147+
globalThis.__flybot__ = { t, call: () => Promise.resolve({}) }
148+
const app = loadApp()
149+
const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Face-to-face room', members: [{ handle: '@alice', ownerName: 'Alice' }], ...room }, past: true } }
150+
const node = nodeByClass(render(app.FaceRoomCard, inst), 'fs-name')
151+
assert.ok(node, 'the Past row draws its title')
152+
return { title: node.props?.title, text: (node.children ?? []).filter(child => typeof child === 'string').join('') }
153+
}
154+
155+
test('#P769b: a Past row’s rebuilt title reads the window’s language too', () => {
156+
try {
157+
const built = faceTitle(hostT, { createdAt: local(19, 14, 5) })
158+
assert.equal(built.title, `面对面 · Alice · 10月19日 14:05`, `a rebuilt title: saw ${JSON.stringify(built.title)}`)
159+
assert.equal(built.text, built.title, 'the row and its hover name the same room')
160+
// A stored auto title carries its own English stamp; it is still display text here.
161+
const stored = faceTitle(hostT, { name: 'Face-to-face · Oct 19 14:05' })
162+
assert.equal(stored.title, `面对面 · Alice · 10月19日 14:05`, `a stored stamp: saw ${JSON.stringify(stored.title)}`)
163+
} finally { delete globalThis.__flybot__ }
164+
})
165+
166+
test('#P769b: an English window keeps the rebuilt title bytes it drew before', () => {
167+
try {
168+
const built = faceTitle(englishT, { createdAt: local(19, 14, 5) })
169+
assert.equal(built.title, 'Face-to-face · Alice · Oct 19 14:05', `the English rebuilt title: saw ${JSON.stringify(built.title)}`)
170+
const stored = faceTitle(englishT, { name: 'Face-to-face · Oct 19 14:05' })
171+
assert.equal(stored.title, 'Face-to-face · Alice · Oct 19 14:05', `the English stored title: saw ${JSON.stringify(stored.title)}`)
172+
} finally { delete globalThis.__flybot__ }
173+
})

0 commit comments

Comments
 (0)