Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3100-social-aria-labels.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The two accessible names on a past face-to-face room card now read in the window's language: a screen reader in a Chinese window says `打开 面对面 · Alice · 10月19日 14:05` for the row and `成员` for the people on it, instead of the English `Open …` and `Members`. Both words come from the dictionary, so an English window keeps exactly the names it had before; only the text read out changes, never the stored room name ([#3100](https://github.com/ThinkFlowLab/FlyBot/issues/3100)).
1 change: 0 additions & 1 deletion core/test/i18n-plugins.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"plugins/social/client.js:Copy link",
"plugins/social/client.js:Dismiss#1",
"plugins/social/client.js:Dismiss#2",
"plugins/social/client.js:Members",
"plugins/social/client.js:New link",
"plugins/social/client.js:Your Bot Social handle",
"plugins/social/client.js:Your Bots",
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name> left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter `<Name> 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.
- 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 `<Name> left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter `<Name> 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 card's two accessible names follow the same rule (#3100): the past row's open button is `tr('Open {title}', …)` and the member stack is `tr('Members')`, so a zh window reads `打开 面对面 · Alice · 10月19日 14:05` and `成员` while an English window keeps `Open Face-to-face · Alice · Oct 19 14:05` and `Members`; both keys already existed in the dictionary (`Open {title}`, `Members`), so `core/src/client/i18n.js` does not change. Tests: `plugins/social/test/zh-face-aria.test.js`. 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).
Expand Down
1 change: 0 additions & 1 deletion patches/ds-bot/files/test/i18n-plugins.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"plugins/social/client.js:Copy link",
"plugins/social/client.js:Dismiss#1",
"plugins/social/client.js:Dismiss#2",
"plugins/social/client.js:Members",
"plugins/social/client.js:New link",
"plugins/social/client.js:Your Bot Social handle",
"plugins/social/client.js:Your Bots",
Expand Down
6 changes: 4 additions & 2 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,7 @@
function qrRsGenerator(degree) {
let poly = [1]
for (let i = 0; i < degree; i += 1) {
const next = new Array(poly.length + 1).fill(0)

Check warning on line 715 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let j = 0; j < poly.length; j += 1) {
next[j] ^= poly[j]
next[j + 1] ^= qrMul(poly[j], QR_GF_EXP[i])
Expand All @@ -723,7 +723,7 @@
}
function qrRsRemainder(bytes, degree) {
const gen = qrRsGenerator(degree)
const res = new Array(bytes.length + degree).fill(0)

Check warning on line 726 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let i = 0; i < bytes.length; i += 1) res[i] = bytes[i]
for (let i = 0; i < bytes.length; i += 1) {
const factor = res[i]
Expand Down Expand Up @@ -797,8 +797,8 @@
}
function qrSkeleton(version) {
const size = version * 4 + 17
const modules = Array.from({ length: size }, () => new Array(size).fill(0))

Check warning on line 800 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
const fixed = Array.from({ length: size }, () => new Array(size).fill(false))

Check warning on line 801 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
const set = (x, y, dark) => { modules[y][x] = dark ? 1 : 0; fixed[y][x] = true }
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy += 1) {
Expand Down Expand Up @@ -1062,7 +1062,7 @@
return out
}
const cleanRoomName = (value, fallback = '') => stripUntrustedMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 1065 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -1106,7 +1106,7 @@
let matrix
try { matrix = qrMatrix(text) } catch { return null }
const size = matrix.size
return h('div', { className: 'fs-qr', 'data-flybot': kind, 'data-modules': size, ...(attrs ?? {}) },

Check warning on line 1109 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
h('svg', {
className: 'fs-qr-svg', width: size, height: size, viewBox: `0 0 ${size} ${size}`,
role: 'img', 'aria-label': label, shapeRendering: 'crispEdges',
Expand Down Expand Up @@ -2086,15 +2086,17 @@
'data-open': openable ? true : undefined,
onClick: openable ? () => (busy ? undefined : open()) : undefined,
...(rowOpens ? {
role: 'button', tabIndex: 0, 'aria-label': `Open ${title}`, 'aria-busy': busy ? true : undefined,
// The two accessible names on this card are read out, never parsed back, so they take the
// dictionary like the visible copy does; the stored room name stays English (#3100).
role: 'button', tabIndex: 0, 'aria-label': tr('Open {title}', { title }), 'aria-busy': busy ? true : undefined,
onKeyDown: (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault?.()
return busy ? undefined : open()
},
} : {}),
},
h('span', { className: 'fs-stack', 'aria-label': 'Members' },
h('span', { className: 'fs-stack', 'aria-label': tr('Members') },
shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle },
h(FaceAvatar, { bot: look(m), size: 20 }))),
extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null),
Expand Down
135 changes: 135 additions & 0 deletions plugins/social/test/zh-face-aria.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// #3100: the two accessible names on a card read English in a zh window. The row's button
// says `Open <title>` and the member stack says `Members`; both are drawn as literals at the draw
// site, so the dictionary never reaches them even though it already carries `Open {title}` and
// `Members`. 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 two `aria-label` values off
// 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 nodeWhere = (tree, match) => {
let hit = null
walk(tree, node => { if (!hit && match(node)) 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 card names does not depend on the runner's zone.
const local = (day, hh, mm) => new Date(2026, 9, day, hh, mm).toISOString()

// The Past card's two accessible names: the row button that opens the room, and the member stack.
const faceCard = (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' }], createdAt: local(19, 14, 5), ...room,
},
past: true,
},
}
const tree = render(app.FaceRoomCard, inst)
const row = nodeWhere(tree, node => node?.props?.role === 'button' && 'aria-label' in (node.props ?? {}))
const stack = nodeWhere(tree, node => node?.props?.className === 'fs-stack')
assert.ok(row, 'the Past row is one button')
assert.ok(stack, 'the row draws its member stack')
return { row: row.props['aria-label'], stack: stack.props['aria-label'] }
}

test('#3100: a Past row’s open button names the room in the window’s language', () => {
try {
const seen = faceCard(hostT)
assert.equal(seen.row, `打开 面对面 · Alice · 10月19日 14:05`, `the row’s accessible name: saw ${JSON.stringify(seen.row)}`)
} finally { delete globalThis.__flybot__ }
})

test('#3100: the member stack’s accessible name reads the window’s language', () => {
try {
const seen = faceCard(hostT)
assert.equal(seen.stack, '成员', `the member stack’s accessible name: saw ${JSON.stringify(seen.stack)}`)
} finally { delete globalThis.__flybot__ }
})

test('#3100: an English window keeps the accessible names it drew before', () => {
try {
const seen = faceCard(englishT)
assert.equal(seen.row, 'Open Face-to-face · Alice · Oct 19 14:05', `the row’s accessible name: saw ${JSON.stringify(seen.row)}`)
assert.equal(seen.stack, 'Members', `the member stack’s accessible name: saw ${JSON.stringify(seen.stack)}`)
} finally { delete globalThis.__flybot__ }
})
Loading