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
66 changes: 66 additions & 0 deletions .changeset/collaboration-comment-thread-i18n-5506.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@object-ui/collaboration': patch
'@object-ui/i18n': patch
---

Localize `@object-ui/collaboration` — `CommentThread` no longer hardcodes English (objectstack#5506)

`@object-ui/collaboration` depended only on `@object-ui/types` and carried every
user-visible string as an English literal, so a `zh` console rendered a Chinese
shell around an English comment thread: "3 comments", "Reply", "Resolve",
"just now", "Add a comment... (use @ to mention)".

The package now takes `@object-ui/i18n` as a dependency and exposes one
translation seam, `useCollaborationTranslation` /
`COLLAB_DEFAULT_TRANSLATIONS`, built on `createSafeTranslation` — the same
factory `data-table`, `form` and `filter-builder` use. Under an `I18nProvider`
it resolves the session locale; with no provider it resolves the English
defaults map, which is what keeps `CommentThread` usable standalone. There is
deliberately no `formatter`/label prop escape hatch: a host that wants
different copy overrides the locale keys, so one thread can never end up half
translated by the bundle and half by props.

The issue listed 13 sites. A site-by-site sweep of the file found **20** — the
seven the original sweep missed are `{n}h ago`, `{n}d ago`, `(edited)`, the
thread's own comment count, the `Oldest`/`Newest` sort options,
`Replying to {name}...`, and the composer's `Send` button. All 20 are keyed
here; leaving any behind would have shipped a thread that is 90% translated.

Two of them carry a second defect on top of being untranslated: the plural
**rule** was compiled into the component, not just the words.

- the header read `` `${n} comment${n !== 1 ? 's' : ''}` ``;
- the reaction chip tooltip read `` n === 1 ? '1 reaction' : `${n} reactions` ``.

Both produced correct *English* — this is not the "1 items" bug objectui#3423
fixed on the tab badge — but the choice between the two forms was English
grammar hardwired into the render path. No locale could apply its own: ru needs
three forms and ja needs none, and neither could ever be expressed no matter
what the packs said.

Both now use the repo's **two-key** plural convention
(`collaboration.commentCount`/`commentCountOne`,
`collaboration.reactionCount`/`reactionCountOne`) rather than an i18next
`_one`/`_other` pair: zh/ja/ko have no separate singular form, so those packs
would legitimately omit the `_one` half and `all-locales-key-parity` reads a
legitimately-absent half as a lost key. Counts are interpolated as strings, so
i18next skips its own plural resolution and the two-key scheme stays in charge.

The reaction tooltip gets a **dedicated** key pair rather than reusing
`detail.reactionCount`: that one interpolates `{{emoji}}`, and at this call
site the emoji is the chip's visible label with nothing to hand the
placeholder — reuse would have left a literal `{{emoji}}` in the accessible
name under every locale.

Relative timestamps stayed word-level: the existing minute/hour/day buckets are
untouched and no date library was introduced. The `>= 7d` branch still uses the
runtime's own `toLocaleDateString()` — that is not a hardcoded English literal,
and pinning it to the session language has its own failure mode (an
unrecognised tag throws into the surrounding `catch`, which would render a raw
ISO string), so it is tracked separately.

`Save` / `Cancel` / `Edit` / `Delete` read from the shared `common` namespace
instead of being re-spelled under `collaboration` — they are the generic action
words, already translated in all ten packs, and a second spelling would only be
a second thing to keep in sync. The 21 genuinely new keys are added to all ten
locale packs with real translations.
1 change: 1 addition & 0 deletions packages/collaboration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"react": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@object-ui/i18n": "workspace:*",
"@object-ui/types": "workspace:*"
},
"devDependencies": {
Expand Down
101 changes: 78 additions & 23 deletions packages/collaboration/src/CommentThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
*/

import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
useCollaborationTranslation,
type CollaborationTranslate,
} from './useCollaborationTranslation';

export interface Comment {
id: string;
Expand Down Expand Up @@ -47,18 +51,34 @@ export interface CommentThreadProps {
className?: string;
}

function formatTimestamp(iso: string): string {
/**
* Relative age of a comment, in the session language.
*
* `t` is threaded in as a parameter rather than read from a hook: this runs
* once per rendered comment from inside `renderComment`, and the buckets are
* unchanged — only the words moved into the locale packs. Counts are
* interpolated as STRINGS on purpose, so i18next skips its own plural
* resolution (`needsPluralHandling` is false for a string `count`) and cannot
* silently start looking for `_one`/`_other` variants this repo does not ship.
*
* The >= 7d branch still uses the runtime's own `toLocaleDateString()`. That is
* not a hardcoded English literal — it already follows the environment locale —
* and pinning it to the session language is a separate change with its own
* failure mode (an unrecognised tag throws `RangeError` straight into the
* `catch` below, which would render the raw ISO string). Tracked separately.
*/
function formatTimestamp(iso: string, t: CollaborationTranslate): string {
try {
const date = new Date(iso);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 1) return t('collaboration.justNow');
if (minutes < 60) return t('collaboration.minutesAgo', { count: String(minutes) });
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) return t('collaboration.hoursAgo', { count: String(hours) });
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
if (days < 7) return t('collaboration.daysAgo', { count: String(days) });
return date.toLocaleDateString();
} catch {
return iso;
Expand Down Expand Up @@ -317,6 +337,13 @@ function renderContent(content: string): React.ReactNode {
*
* Renders a list of comments with author avatars, timestamps,
* reply functionality, and an @mention suggestions popup.
*
* Every user-visible string resolves through `useCollaborationTranslation`
* (objectstack#5506): the session locale under an `I18nProvider`, and the
* English `COLLAB_DEFAULT_TRANSLATIONS` map with no provider mounted. There is
* deliberately no `formatter`/label prop escape hatch — a host that wants
* different copy overrides the locale keys, so one thread cannot end up half
* translated by the bundle and half by props.
*/
export function CommentThread({
threadId,
Expand All @@ -340,6 +367,7 @@ export function CommentThread({
const [mentionIndex, setMentionIndex] = useState(0);
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest');
const inputRef = useRef<HTMLTextAreaElement>(null);
const { t } = useCollaborationTranslation();

const filteredMentions = useMemo(() => {
if (mentionQuery === null) return [];
Expand Down Expand Up @@ -483,9 +511,9 @@ export function CommentThread({
// Header
React.createElement('div', { style: styles.commentHeader },
React.createElement('span', { style: styles.authorName }, comment.author.name),
React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt)),
React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t)),
comment.updatedAt
? React.createElement('span', { style: styles.timestamp }, '(edited)')
? React.createElement('span', { style: styles.timestamp }, t('collaboration.edited'))
: null,
),
// Content or edit input
Expand All @@ -500,11 +528,11 @@ export function CommentThread({
React.createElement('button', {
onClick: handleEditSave,
style: { ...styles.submitBtn, padding: '4px 10px', fontSize: '12px' },
}, 'Save'),
}, t('common.save')),
React.createElement('button', {
onClick: () => { setEditingId(null); setEditValue(''); },
style: { ...styles.actionBtn },
}, 'Cancel'),
}, t('common.cancel')),
)
: React.createElement('div', { style: styles.content }, renderContent(comment.content)),
// Reactions display
Expand All @@ -517,21 +545,30 @@ export function CommentThread({
...(userIds.includes(currentUser.id) ? styles.reactionBtnActive : {}),
},
onClick: () => onReaction?.(comment.id, emoji),
title: userIds.length === 1 ? '1 reaction' : `${userIds.length} reactions`,
// Dedicated key pair — `detail.reactionCount` interpolates an
// `{{emoji}}` this tooltip has no value for (the emoji is the
// button's visible label), so reusing it would leave a literal
// `{{emoji}}` in the accessible name under every locale.
title: t(
userIds.length === 1
? 'collaboration.reactionCountOne'
: 'collaboration.reactionCount',
{ count: String(userIds.length) },
),
}, `${emoji} ${userIds.length}`),
),
onReaction && React.createElement('button', {
style: styles.reactionPicker,
onClick: () => onReaction(comment.id, '👍'),
title: 'Add thumbs up',
title: t('collaboration.addThumbsUp'),
}, '+'),
),
// Actions
!isEditing && React.createElement('div', { style: styles.actions },
React.createElement('button', {
style: styles.actionBtn,
onClick: () => setReplyTo(comment.id),
}, 'Reply'),
}, t('collaboration.reply')),
onReaction && React.createElement('button', {
style: styles.actionBtn,
onClick: () => onReaction(comment.id, '👍'),
Expand All @@ -543,11 +580,11 @@ export function CommentThread({
isOwner && onEditComment && React.createElement('button', {
style: styles.actionBtn,
onClick: () => handleEdit(comment.id),
}, 'Edit'),
}, t('common.edit')),
isOwner && onDeleteComment && React.createElement('button', {
style: styles.actionBtn,
onClick: () => onDeleteComment(comment.id),
}, 'Delete'),
}, t('common.delete')),
),
),
);
Expand All @@ -561,23 +598,33 @@ export function CommentThread({
// Header
React.createElement('div', { style: styles.header },
React.createElement('span', null,
`${comments.length} comment${comments.length !== 1 ? 's' : ''}`,
resolved ? ' · Resolved' : '',
// Two keys instead of an English `s` glued on at render time. The old
// `` `${n} comment${n !== 1 ? 's' : ''}` `` produced correct *English*
// — the bug is that the plural RULE was compiled into the component,
// so no locale could apply its own (ru needs three forms, ja needs
// none, and neither could ever be expressed).
t(
comments.length === 1
? 'collaboration.commentCountOne'
: 'collaboration.commentCount',
{ count: String(comments.length) },
),
resolved ? t('collaboration.resolvedSuffix') : '',
),
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
React.createElement('select', {
value: sortOrder,
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSortOrder(e.target.value as 'newest' | 'oldest'),
style: styles.sortSelect,
'aria-label': 'Sort comments',
'aria-label': t('collaboration.sortComments'),
},
React.createElement('option', { value: 'oldest' }, 'Oldest'),
React.createElement('option', { value: 'newest' }, 'Newest'),
React.createElement('option', { value: 'oldest' }, t('collaboration.sortOldest')),
React.createElement('option', { value: 'newest' }, t('collaboration.sortNewest')),
),
onResolve && React.createElement('button', {
style: styles.resolveBtn,
onClick: () => onResolve(!resolved),
}, resolved ? 'Reopen' : 'Resolve'),
}, resolved ? t('collaboration.reopen') : t('collaboration.resolve')),
),
),
// Comments list
Expand All @@ -593,7 +640,15 @@ export function CommentThread({
replyTo && React.createElement('div', {
style: { padding: '4px 12px', fontSize: '12px', color: '#64748b', backgroundColor: '#f8fafc', display: 'flex', justifyContent: 'space-between' },
},
React.createElement('span', null, `Replying to ${comments.find(c => c.id === replyTo)?.author.name ?? 'comment'}...`),
// Two whole sentences rather than one sentence plus a translatable word
// standing in for a name: languages that inflect around the addressee
// cannot build the no-author case out of the `{{name}}` form.
React.createElement('span', null, (() => {
const replyToName = comments.find(c => c.id === replyTo)?.author.name;
return replyToName
? t('collaboration.replyingTo', { name: replyToName })
: t('collaboration.replyingToComment');
})()),
React.createElement('button', {
style: styles.actionBtn,
onClick: () => setReplyTo(null),
Expand Down Expand Up @@ -631,7 +686,7 @@ export function CommentThread({
value: inputValue,
onChange: handleInputChange,
onKeyDown: handleKeyDown,
placeholder: 'Add a comment... (use @ to mention)',
placeholder: t('collaboration.commentPlaceholder'),
style: styles.textarea,
rows: 1,
}),
Expand All @@ -642,7 +697,7 @@ export function CommentThread({
...styles.submitBtn,
...(!inputValue.trim() ? styles.submitBtnDisabled : {}),
},
}, 'Send'),
}, t('collaboration.send')),
),
);
}
Loading
Loading