diff --git a/.changeset/collaboration-comment-thread-i18n-5506.md b/.changeset/collaboration-comment-thread-i18n-5506.md new file mode 100644 index 0000000000..f77b6f1fa7 --- /dev/null +++ b/.changeset/collaboration-comment-thread-i18n-5506.md @@ -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. diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index c8b31410fe..ad16a6a29d 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -37,6 +37,7 @@ "react": "^18.0.0 || ^19.0.0" }, "dependencies": { + "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*" }, "devDependencies": { diff --git a/packages/collaboration/src/CommentThread.tsx b/packages/collaboration/src/CommentThread.tsx index 397b7b3b08..b90b555073 100644 --- a/packages/collaboration/src/CommentThread.tsx +++ b/packages/collaboration/src/CommentThread.tsx @@ -7,6 +7,10 @@ */ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { + useCollaborationTranslation, + type CollaborationTranslate, +} from './useCollaborationTranslation'; export interface Comment { id: string; @@ -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; @@ -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, @@ -340,6 +367,7 @@ export function CommentThread({ const [mentionIndex, setMentionIndex] = useState(0); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest'); const inputRef = useRef(null); + const { t } = useCollaborationTranslation(); const filteredMentions = useMemo(() => { if (mentionQuery === null) return []; @@ -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 @@ -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 @@ -517,13 +545,22 @@ 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 @@ -531,7 +568,7 @@ export function CommentThread({ 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, '👍'), @@ -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')), ), ), ); @@ -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) => 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 @@ -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), @@ -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, }), @@ -642,7 +697,7 @@ export function CommentThread({ ...styles.submitBtn, ...(!inputValue.trim() ? styles.submitBtnDisabled : {}), }, - }, 'Send'), + }, t('collaboration.send')), ), ); } diff --git a/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx b/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx new file mode 100644 index 0000000000..82908ae6f1 --- /dev/null +++ b/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx @@ -0,0 +1,318 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `CommentThread` speaks the session language — objectstack#5506 / objectui#3424. + * + * The whole package shipped its copy as English literals, 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)". + * + * ── Directions: predicted first, then corrected by the run ──────────────── + * Reverting `CommentThread.tsx` to `origin/main` and keeping this file turns + * all 13 `zh` / `de` / `ja` cases RED and leaves all 4 `en` cases GREEN. + * + * The `en` cases being green on BOTH sides is the point, not a gap: the + * English copy must survive the move into the locale packs, and a case that + * flipped would mean the copy changed under us. + * + * One prediction was WRONG and is recorded here rather than quietly dropped. + * The `en` singular case (`1 comment`) was expected to be red-before, on the + * assumption that the header glued a bare `s` on with no singular branch — + * the way `page:tabs`' count badge did in objectstack#5506's earlier half + * (objectui#3423), where a one-row list announced "1 items". It does not: + * `origin/main` reads `` `${n} comment${n !== 1 ? 's' : ''}` `` and the + * reaction tooltip reads `n === 1 ? '1 reaction' : …`, so both already + * produced correct English. The defect they carry is different — the plural + * RULE is compiled into the component, so no locale can apply its own (ru + * needs three forms, ja needs none, and neither could ever be expressed). + * The `de` case above is what actually pins that, and the `en` singular cases + * stay as green-both-sides copy pins. + * + * The provider-less English fallback is asserted in + * `comment-thread-no-provider-fallback.test.tsx` and cannot live here — see + * that file's header for the react-i18next global-instance reason. + */ + +import type { ComponentProps } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { CommentThread, type Comment } from '../CommentThread'; + +const alice = { id: 'u_alice', name: 'Alice Chen' }; +const bob = { id: 'u_bob', name: 'Bob Ito' }; + +/** Ages chosen to land squarely inside each bucket, never on a boundary. */ +const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000).toISOString(); + +const baseComments: Comment[] = [ + { + id: 'c1', + author: alice, + content: 'First pass looks good.', + mentions: [], + createdAt: minutesAgo(0), + }, + { + id: 'c2', + author: bob, + content: 'Agreed.', + mentions: [], + createdAt: minutesAgo(5), + updatedAt: minutesAgo(4), + reactions: { '👍': [alice.id, bob.id], '❤️': [alice.id] }, + }, +]; + +function renderThread( + language: string, + overrides: Partial> = {}, +) { + return render( + + {}} + onEditComment={() => {}} + onDeleteComment={() => {}} + onResolve={() => {}} + onReaction={() => {}} + {...overrides} + /> + , + ); +} + +afterEach(() => cleanup()); + +describe('CommentThread header (objectstack#5506)', () => { + it('counts, sorts and resolves in English under an en session', () => { + renderThread('en'); + + expect(screen.getByText('2 comments')).toBeTruthy(); + expect(screen.getByLabelText('Sort comments')).toBeTruthy(); + expect(screen.getByText('Oldest')).toBeTruthy(); + expect(screen.getByText('Newest')).toBeTruthy(); + expect(screen.getByText('Resolve')).toBeTruthy(); + }); + + it('counts, sorts and resolves in Chinese under a zh session', () => { + renderThread('zh'); + + expect(screen.getByText('2 条评论')).toBeTruthy(); + expect(screen.getByLabelText('评论排序')).toBeTruthy(); + expect(screen.getByText('最早优先')).toBeTruthy(); + expect(screen.getByText('最新优先')).toBeTruthy(); + expect(screen.getByText('标记已解决')).toBeTruthy(); + // The English literals are gone, not merely shadowed. + expect(screen.queryByText('2 comments')).toBeNull(); + expect(screen.queryByLabelText('Sort comments')).toBeNull(); + }); + + it('appends the resolved marker in the session language', () => { + renderThread('zh', { resolved: true }); + + expect(screen.getByText('2 条评论 · 已解决')).toBeTruthy(); + // Resolved threads offer "reopen", not "resolve". + expect(screen.getByText('重新打开')).toBeTruthy(); + }); + + /** + * German is the load-bearing plural case: unlike zh it HAS a distinct + * singular, so a collapsed one-key scheme would show up right here. + */ + it('splits singular and plural under a de session', () => { + renderThread('de', { comments: [baseComments[0]] }); + expect(screen.getByText('1 Kommentar')).toBeTruthy(); + cleanup(); + + renderThread('de'); + expect(screen.getByText('2 Kommentare')).toBeTruthy(); + }); + + /** + * Green on both sides — a copy pin, not a demonstration of the bug. See the + * file header: `origin/main` did have a singular branch here, contrary to + * the prediction this test was first written under. + */ + it('has a real English singular for a one-comment thread', () => { + renderThread('en', { comments: [baseComments[0]] }); + + expect(screen.getByText('1 comment')).toBeTruthy(); + expect(screen.queryByText('1 comments')).toBeNull(); + }); +}); + +describe('CommentThread relative timestamps (objectstack#5506)', () => { + it('reads English relative ages under an en session', () => { + renderThread('en', { + comments: [ + { ...baseComments[0], id: 'a', createdAt: minutesAgo(0) }, + { ...baseComments[0], id: 'b', createdAt: minutesAgo(7) }, + { ...baseComments[0], id: 'c', createdAt: minutesAgo(3 * 60) }, + { ...baseComments[0], id: 'd', createdAt: minutesAgo(2 * 24 * 60) }, + ], + }); + + expect(screen.getByText('just now')).toBeTruthy(); + expect(screen.getByText('7m ago')).toBeTruthy(); + expect(screen.getByText('3h ago')).toBeTruthy(); + expect(screen.getByText('2d ago')).toBeTruthy(); + }); + + it('reads Chinese relative ages under a zh session', () => { + renderThread('zh', { + comments: [ + { ...baseComments[0], id: 'a', createdAt: minutesAgo(0) }, + { ...baseComments[0], id: 'b', createdAt: minutesAgo(7) }, + { ...baseComments[0], id: 'c', createdAt: minutesAgo(3 * 60) }, + { ...baseComments[0], id: 'd', createdAt: minutesAgo(2 * 24 * 60) }, + ], + }); + + expect(screen.getByText('刚刚')).toBeTruthy(); + expect(screen.getByText('7 分钟前')).toBeTruthy(); + expect(screen.getByText('3 小时前')).toBeTruthy(); + expect(screen.getByText('2 天前')).toBeTruthy(); + expect(screen.queryByText('just now')).toBeNull(); + expect(screen.queryByText('7m ago')).toBeNull(); + }); + + it('marks an edited comment in the session language', () => { + renderThread('ja'); + expect(screen.getByText('(編集済み)')).toBeTruthy(); + expect(screen.queryByText('(edited)')).toBeNull(); + }); +}); + +describe('CommentThread reaction tooltip (objectstack#5506)', () => { + /** + * The tooltip gets its OWN key pair. `detail.reactionCount` interpolates an + * `{{emoji}}`, and here the emoji is the chip's visible label with nothing to + * hand that placeholder — reuse would leave a literal `{{emoji}}` in the + * accessible name. These assertions are what pins that. + */ + it('names reaction chips in English, with singular and plural', () => { + renderThread('en'); + + expect(screen.getByTitle('2 reactions')).toBeTruthy(); + expect(screen.getByTitle('1 reaction')).toBeTruthy(); + expect(screen.getByTitle('Add thumbs up')).toBeTruthy(); + }); + + it('names reaction chips in the session language', () => { + renderThread('zh'); + + expect(screen.getByTitle('2 个回应')).toBeTruthy(); + expect(screen.getByTitle('点赞')).toBeTruthy(); + expect(screen.queryByTitle('2 reactions')).toBeNull(); + expect(screen.queryByTitle('Add thumbs up')).toBeNull(); + }); + + it('never leaks an unresolved placeholder into the tooltip', () => { + renderThread('de'); + + const tooltips = Array.from(document.querySelectorAll('[title]')).map((n) => + n.getAttribute('title'), + ); + expect(tooltips).toContain('2 Reaktionen'); + expect(tooltips).toContain('1 Reaktion'); + // The bug reuse of `detail.reactionCount` would have produced. + expect(tooltips.some((v) => v?.includes('{{'))).toBe(false); + }); +}); + +describe('CommentThread per-comment actions (objectstack#5506)', () => { + it('labels reply / edit / delete in the session language', () => { + renderThread('zh'); + + expect(screen.getAllByText('回复').length).toBeGreaterThan(0); + expect(screen.getAllByText('编辑').length).toBeGreaterThan(0); + expect(screen.getAllByText('删除').length).toBeGreaterThan(0); + expect(screen.queryByText('Reply')).toBeNull(); + expect(screen.queryByText('Delete')).toBeNull(); + }); + + it('labels the inline editor save / cancel in the session language', () => { + renderThread('zh'); + + // Only the current user's own comment offers Edit; c1 is Alice's. + fireEvent.click(screen.getAllByText('编辑')[0]); + + expect(screen.getByText('保存')).toBeTruthy(); + expect(screen.getByText('取消')).toBeTruthy(); + expect(screen.queryByText('Save')).toBeNull(); + expect(screen.queryByText('Cancel')).toBeNull(); + }); + + it('translates the reply banner, interpolating the author name', () => { + renderThread('zh'); + + fireEvent.click(screen.getAllByText('回复')[0]); + + expect(screen.getByText('正在回复 Alice Chen…')).toBeTruthy(); + expect(screen.queryByText('Replying to Alice Chen...')).toBeNull(); + }); + + /** + * The no-author half of the banner: the comment being replied to is gone + * (deleted by someone else mid-reply — the collaboration case this package + * exists for), so there is no `{{name}}` to interpolate and a separate whole + * sentence is used instead of substituting a noun. + */ + it('translates the reply banner when the target comment vanished', () => { + const { rerender } = renderThread('zh'); + + fireEvent.click(screen.getAllByText('回复')[0]); + rerender( + + {}} + onEditComment={() => {}} + onDeleteComment={() => {}} + onResolve={() => {}} + onReaction={() => {}} + /> + , + ); + + expect(screen.getByText('正在回复该评论…')).toBeTruthy(); + expect(screen.queryByText('Replying to comment...')).toBeNull(); + }); +}); + +describe('CommentThread composer (objectstack#5506)', () => { + it('translates the placeholder and the send button', () => { + renderThread('zh'); + + expect(screen.getByPlaceholderText('添加评论…(输入 @ 提及他人)')).toBeTruthy(); + expect(screen.getByText('发送')).toBeTruthy(); + expect(screen.queryByPlaceholderText('Add a comment... (use @ to mention)')).toBeNull(); + expect(screen.queryByText('Send')).toBeNull(); + }); + + it('keeps the English copy under an en session', () => { + renderThread('en'); + + expect(screen.getByPlaceholderText('Add a comment... (use @ to mention)')).toBeTruthy(); + expect(screen.getByText('Send')).toBeTruthy(); + }); + + it('translates the placeholder for ja too', () => { + renderThread('ja'); + + expect(screen.getByPlaceholderText('コメントを追加…(@ でメンション)')).toBeTruthy(); + expect(screen.getByText('送信')).toBeTruthy(); + }); +}); diff --git a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx new file mode 100644 index 0000000000..5823e2dc96 --- /dev/null +++ b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx @@ -0,0 +1,205 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Every string objectstack#5506 / objectui#3424 moved into the locale packs + * still resolves to ENGLISH when no `I18nProvider` is mounted. + * + * `CommentThread` is exported for standalone use and its host may mount no + * ObjectUI shell at all. Routing a literal through `t()` without a working + * default is exactly how a label turns into a raw dotted key + * (`collaboration.reply`) in someone else's app — and it would do so silently, + * because `fallbackLng: 'en'` never fires when there is no i18next instance to + * fall back inside of. `COLLAB_DEFAULT_TRANSLATIONS` is the thing that has to + * hold, and this file is what holds it. + * + * ── Directions: predicted first, then corrected by the run ──────────────── + * **Every** assertion in this file is GREEN on BOTH sides of the change. + * That is the invariant, not a missing test: the English copy is supposed to + * be byte-identical before and after, so a flip would mean the copy moved. + * What this file catches is a break introduced by *this* change — a key wired + * into the component but missing (or mis-spelled) in + * `COLLAB_DEFAULT_TRANSLATIONS` goes red here and nowhere else. + * + * One prediction was wrong and is recorded rather than dropped: the singular + * comment count was expected to be red-before, on the assumption that the + * header glued a bare `s` on with no singular branch — the way `page:tabs`' + * badge did in objectui#3423 ("1 items"). `origin/main` in fact reads + * `` `${n} comment${n !== 1 ? 's' : ''}` `` and already produced correct + * English, so that case is green on both sides like the rest. The real defect + * in those two sites is that the plural RULE was compiled into the component + * and no locale could apply its own; the `de` cases in + * `comment-thread-i18n.test.tsx` are what pin that. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`. So the moment any test in a + * file mounts ``, every later + * "no provider" render in that same file silently resolves against the Chinese + * instance — a file that looks green while asserting nothing about the + * fallback, or a baffling red where a provider-less thread renders 中文. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** (Importing `@object-ui/i18n` for a pure + * helper would be safe — nothing calls `createI18n` at module scope — but the + * cheapest way to keep that true is to not import it at all.) + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { CommentThread, type Comment } from '../CommentThread'; +import { COLLAB_DEFAULT_TRANSLATIONS } from '../useCollaborationTranslation'; + +const alice = { id: 'u_alice', name: 'Alice Chen' }; +const bob = { id: 'u_bob', name: 'Bob Ito' }; + +const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000).toISOString(); + +const comments: Comment[] = [ + { id: 'c1', author: alice, content: 'First pass looks good.', mentions: [], createdAt: minutesAgo(0) }, + { + id: 'c2', + author: bob, + content: 'Agreed.', + mentions: [], + createdAt: minutesAgo(7), + updatedAt: minutesAgo(6), + reactions: { '👍': [alice.id, bob.id], '❤️': [alice.id] }, + }, +]; + +function renderBare(overrides: Record = {}) { + return render( + {}} + onEditComment={() => {}} + onDeleteComment={() => {}} + onResolve={() => {}} + onReaction={() => {}} + {...overrides} + />, + ); +} + +afterEach(() => cleanup()); + +describe('CommentThread with no I18nProvider — English fallback (objectstack#5506)', () => { + it('renders the header, sort control and resolve button in English', () => { + renderBare(); + + expect(screen.getByText('2 comments')).toBeTruthy(); + expect(screen.getByLabelText('Sort comments')).toBeTruthy(); + expect(screen.getByText('Oldest')).toBeTruthy(); + expect(screen.getByText('Newest')).toBeTruthy(); + expect(screen.getByText('Resolve')).toBeTruthy(); + }); + + it('renders relative timestamps and the edited marker in English', () => { + renderBare(); + + expect(screen.getByText('just now')).toBeTruthy(); + expect(screen.getByText('7m ago')).toBeTruthy(); + expect(screen.getByText('(edited)')).toBeTruthy(); + }); + + it('renders reaction tooltips and per-comment actions in English', () => { + renderBare(); + + expect(screen.getByTitle('2 reactions')).toBeTruthy(); + expect(screen.getByTitle('1 reaction')).toBeTruthy(); + expect(screen.getByTitle('Add thumbs up')).toBeTruthy(); + expect(screen.getAllByText('Reply').length).toBeGreaterThan(0); + expect(screen.getByText('Edit')).toBeTruthy(); + expect(screen.getByText('Delete')).toBeTruthy(); + }); + + it('renders the inline editor and reply banner in English', () => { + renderBare(); + + fireEvent.click(screen.getByText('Edit')); + expect(screen.getByText('Save')).toBeTruthy(); + expect(screen.getByText('Cancel')).toBeTruthy(); + + fireEvent.click(screen.getAllByText('Reply')[0]); + expect(screen.getByText('Replying to Bob Ito...')).toBeTruthy(); + }); + + it('renders the composer in English', () => { + renderBare(); + + expect(screen.getByPlaceholderText('Add a comment... (use @ to mention)')).toBeTruthy(); + expect(screen.getByText('Send')).toBeTruthy(); + }); + + it('appends the resolved marker in English', () => { + renderBare({ resolved: true }); + + expect(screen.getByText('2 comments · Resolved')).toBeTruthy(); + expect(screen.getByText('Reopen')).toBeTruthy(); + }); + + /** + * A copy pin, green on both sides — see the file header for why this was + * expected to be the one red-before case and is not. + */ + it('has a real English singular for a one-comment thread', () => { + renderBare({ comments: [comments[0]] }); + + expect(screen.getByText('1 comment')).toBeTruthy(); + expect(screen.queryByText('1 comments')).toBeNull(); + }); + + /** + * The failure mode this whole file exists to catch: a key wired into the + * component but absent from the defaults map renders as its own dotted name. + */ + it('never renders a raw i18n key', () => { + const { container } = renderBare({ resolved: true }); + + expect(container.textContent).not.toMatch(/collaboration\.\w+/); + expect(container.textContent).not.toMatch(/common\.\w+/); + expect(container.innerHTML).not.toMatch(/\{\{\w+\}\}/); + }); +}); + +describe('COLLAB_DEFAULT_TRANSLATIONS is the package-wide English source', () => { + /** + * The defaults map is the *only* English copy left in the package, so an + * empty or half-populated map would make every assertion above pass for the + * wrong reason (a key that resolves to itself still "renders"). + */ + it('covers every key the thread asks for, with no placeholder-only values', () => { + const keys = Object.keys(COLLAB_DEFAULT_TRANSLATIONS); + + expect(keys.length).toBeGreaterThanOrEqual(25); + expect(keys).toContain('collaboration.reactionCount'); + expect(keys).toContain('collaboration.reactionCountOne'); + // The shared action words are borrowed from `common`, not re-spelled. + expect(keys).toContain('common.save'); + expect(keys.some((k) => k.startsWith('collaboration.save'))).toBe(false); + + for (const [key, value] of Object.entries(COLLAB_DEFAULT_TRANSLATIONS)) { + expect(value, `${key} has no English copy`).toBeTruthy(); + expect(value, `${key} is just its own key`).not.toBe(key); + } + }); + + /** + * The reaction tooltip must NOT be `detail.reactionCount`, which carries an + * `{{emoji}}` this call site has no value for. + */ + it('keeps the reaction tooltip free of an {{emoji}} placeholder', () => { + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCount']).toBe('{{count}} reactions'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCountOne']).toBe('{{count}} reaction'); + }); +}); diff --git a/packages/collaboration/src/index.ts b/packages/collaboration/src/index.ts index a92fb6396d..667f42a4ba 100644 --- a/packages/collaboration/src/index.ts +++ b/packages/collaboration/src/index.ts @@ -49,6 +49,16 @@ export { type CommentThreadProps, } from './CommentThread'; +// This package's i18n seam. Exported like `plugin-detail`'s +// `useDetailTranslation` / `DETAIL_DEFAULT_TRANSLATIONS` so a host can read the +// English defaults (e.g. to seed its own bundle) and so anything added to this +// package later translates through the same map instead of a second one. +export { + useCollaborationTranslation, + COLLAB_DEFAULT_TRANSLATIONS, + type CollaborationTranslate, +} from './useCollaborationTranslation'; + export { useMentionNotifications, type MentionNotificationsConfig, diff --git a/packages/collaboration/src/useCollaborationTranslation.ts b/packages/collaboration/src/useCollaborationTranslation.ts new file mode 100644 index 0000000000..4201b963be --- /dev/null +++ b/packages/collaboration/src/useCollaborationTranslation.ts @@ -0,0 +1,105 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * i18n entry point for `@object-ui/collaboration` (objectstack#5506, objectui#3424). + * + * The package shipped every user-visible string as an English literal, so a + * `zh` session read a Chinese console with an English comment thread inside it. + * This is the package's single translation seam: components call + * {@link useCollaborationTranslation} and never hold a literal. + * + * `createSafeTranslation` (the same factory `data-table`, `form` and + * `filter-builder` use) does two things at once: + * + * 1. under an `I18nProvider` it resolves against the session locale, and + * 2. with **no** provider it resolves against {@link COLLAB_DEFAULT_TRANSLATIONS}. + * + * (2) is not a nicety — it is the contract. `CommentThread` is exported for + * standalone use with no ObjectUI shell around it, and its host may mount no + * provider at all. Routing a literal through `t()` without a working default + * would turn every label into a raw dotted key on that path. The defaults map + * below is therefore the authoritative English copy of this package, and it + * must stay byte-identical to the `en` locale pack's `collaboration` namespace. + * + * Plural keys come in PAIRS (`…Count` / `…CountOne`) rather than i18next + * `_one`/`_other` suffixes: 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. Same convention as + * `detail.reactionCount` / `common.itemCount`. + */ +import { createSafeTranslation } from '@object-ui/i18n'; + +/** + * English fallback copy for everything this package renders. + * + * Four entries are borrowed from the shared `common` namespace rather than + * duplicated under `collaboration`: `Save` / `Cancel` / `Edit` / `Delete` are + * the generic action words, already translated in all ten packs, and a second + * spelling of them would only be a second thing to keep in sync. + */ +export const COLLAB_DEFAULT_TRANSLATIONS: Record = { + // Thread header + 'collaboration.commentCount': '{{count}} comments', + 'collaboration.commentCountOne': '{{count}} comment', + 'collaboration.resolvedSuffix': ' · Resolved', + 'collaboration.sortComments': 'Sort comments', + 'collaboration.sortOldest': 'Oldest', + 'collaboration.sortNewest': 'Newest', + 'collaboration.resolve': 'Resolve', + 'collaboration.reopen': 'Reopen', + // Relative timestamps. Word-level entries, not a date-formatting layer — the + // buckets stay exactly where the component already put them and no date + // library is introduced. + 'collaboration.justNow': 'just now', + 'collaboration.minutesAgo': '{{count}}m ago', + 'collaboration.hoursAgo': '{{count}}h ago', + 'collaboration.daysAgo': '{{count}}d ago', + 'collaboration.edited': '(edited)', + // Reactions. A DEDICATED pair, deliberately not `detail.reactionCount` — + // that one interpolates `{{emoji}}` and this tooltip has no emoji to hand it + // (the emoji is the button's visible content), so reuse would render a + // stray `{{emoji}}` under every locale. + 'collaboration.reactionCount': '{{count}} reactions', + 'collaboration.reactionCountOne': '{{count}} reaction', + 'collaboration.addThumbsUp': 'Add thumbs up', + // Per-comment actions and the reply banner + 'collaboration.reply': 'Reply', + 'collaboration.replyingTo': 'Replying to {{name}}...', + 'collaboration.replyingToComment': 'Replying to comment...', + // Composer + 'collaboration.commentPlaceholder': 'Add a comment... (use @ to mention)', + 'collaboration.send': 'Send', + // Shared action words — see the note above. + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.edit': 'Edit', + 'common.delete': 'Delete', +}; + +/** + * Session-locale translation for this package, with the English map above as + * the no-provider fallback. + * + * The probe key must be one this package owns: a shared `common.*` key would + * also resolve under an unrelated bundle and report "i18n is configured" for a + * host that never loaded the collaboration namespace. + */ +export const useCollaborationTranslation = createSafeTranslation( + COLLAB_DEFAULT_TRANSLATIONS, + 'collaboration.commentPlaceholder', +); + +/** + * The translate function `useCollaborationTranslation` hands back. + * + * Derived from the hook rather than hand-written so module-level helpers that + * take `t` as a parameter (see `formatTimestamp`) cannot drift from whichever + * half of the union — real i18next `t` or the English fallback — is live. + */ +export type CollaborationTranslate = ReturnType['t']; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index dff27533c9..7f25c1776a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3051,6 +3051,29 @@ const ar = { addSort: "إضافة ترتيب", removeSort: "إزالة الترتيب", }, + collaboration: { + commentCount: "{{count}} تعليقات", + commentCountOne: "{{count}} تعليق", + resolvedSuffix: " · تم الحل", + sortComments: "ترتيب التعليقات", + sortOldest: "الأقدم أولاً", + sortNewest: "الأحدث أولاً", + resolve: "وضع علامة كمحلول", + reopen: "إعادة الفتح", + justNow: "الآن", + minutesAgo: "قبل {{count}} دقيقة", + hoursAgo: "قبل {{count}} ساعة", + daysAgo: "قبل {{count}} يوم", + edited: "(تم التعديل)", + reactionCount: "{{count}} تفاعلات", + reactionCountOne: "{{count}} تفاعل", + addThumbsUp: "إضافة إعجاب", + reply: "رد", + replyingTo: "الرد على {{name}}…", + replyingToComment: "الرد على التعليق…", + commentPlaceholder: "أضف تعليقًا… (استخدم @ للإشارة)", + send: "إرسال", + }, }; export default ar; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7f73eb02b1..45149a2b4b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3051,6 +3051,29 @@ const de = { addSort: "Sortierung hinzufügen", removeSort: "Sortierung entfernen", }, + collaboration: { + commentCount: "{{count}} Kommentare", + commentCountOne: "{{count}} Kommentar", + resolvedSuffix: " · Gelöst", + sortComments: "Kommentare sortieren", + sortOldest: "Älteste zuerst", + sortNewest: "Neueste zuerst", + resolve: "Lösen", + reopen: "Erneut öffnen", + justNow: "gerade eben", + minutesAgo: "vor {{count}} Min.", + hoursAgo: "vor {{count}} Std.", + daysAgo: "vor {{count}} T.", + edited: "(bearbeitet)", + reactionCount: "{{count}} Reaktionen", + reactionCountOne: "{{count}} Reaktion", + addThumbsUp: "Daumen hoch hinzufügen", + reply: "Antworten", + replyingTo: "Antwort an {{name}} …", + replyingToComment: "Antwort auf Kommentar …", + commentPlaceholder: "Kommentar hinzufügen … (@ für Erwähnungen)", + send: "Senden", + }, }; export default de; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 2bf1e43f8d..ed13aa6ff5 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3164,6 +3164,52 @@ const en = { addSort: 'Add sort', removeSort: 'Remove sort', }, + // `@object-ui/collaboration` — the comment thread's copy (objectstack#5506, + // objectui#3424). The package used to carry every one of these as an English + // literal, so a zh session read a Chinese console with an English comment + // thread inside it. `COLLAB_DEFAULT_TRANSLATIONS` in that package mirrors + // this namespace verbatim as its no-provider fallback; keep the two in step. + // + // The generic action words (Save / Cancel / Edit / Delete) are NOT repeated + // here — the thread reads them from `common`. + collaboration: { + // Thread header. `commentCount`/`commentCountOne` are two keys, NOT an + // i18next `_one`/`_other` pair — see the `reactionCount` note under + // `detail`: 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. + commentCount: '{{count}} comments', + commentCountOne: '{{count}} comment', + // Appended to the count, separator included, so a translator owns the + // whole phrase rather than inheriting an English-shaped ` · ` glue. + resolvedSuffix: ' · Resolved', + sortComments: 'Sort comments', + sortOldest: 'Oldest', + sortNewest: 'Newest', + resolve: 'Resolve', + reopen: 'Reopen', + // Relative comment age. Word-level entries only — the component keeps its + // existing minute/hour/day buckets and no date library was introduced. + justNow: 'just now', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + daysAgo: '{{count}}d ago', + edited: '(edited)', + // Reaction-chip tooltip. A DEDICATED pair rather than `detail.reactionCount`: + // that one interpolates `{{emoji}}`, and here the emoji is the chip's + // visible label with nothing to hand the placeholder. + reactionCount: '{{count}} reactions', + reactionCountOne: '{{count}} reaction', + addThumbsUp: 'Add thumbs up', + reply: 'Reply', + replyingTo: 'Replying to {{name}}...', + // The no-author-found half of the reply banner, as a whole sentence: + // languages that inflect around the addressee cannot build it by + // substituting a noun into the `{{name}}` form. + replyingToComment: 'Replying to comment...', + commentPlaceholder: 'Add a comment... (use @ to mention)', + send: 'Send', + }, } as const; export default en; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c4e317c3c2..5e8fa8b278 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3056,6 +3056,29 @@ const es = { addSort: "Agregar orden", removeSort: "Quitar orden", }, + collaboration: { + commentCount: "{{count}} comentarios", + commentCountOne: "{{count}} comentario", + resolvedSuffix: " · Resuelto", + sortComments: "Ordenar comentarios", + sortOldest: "Más antiguos", + sortNewest: "Más recientes", + resolve: "Resolver", + reopen: "Reabrir", + justNow: "ahora mismo", + minutesAgo: "hace {{count}} min", + hoursAgo: "hace {{count}} h", + daysAgo: "hace {{count}} d", + edited: "(editado)", + reactionCount: "{{count}} reacciones", + reactionCountOne: "{{count}} reacción", + addThumbsUp: "Agregar me gusta", + reply: "Responder", + replyingTo: "Respondiendo a {{name}}…", + replyingToComment: "Respondiendo al comentario…", + commentPlaceholder: "Agregar un comentario… (usa @ para mencionar)", + send: "Enviar", + }, }; export default es; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index c9e6726a4c..3f8a8dccc5 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3051,6 +3051,29 @@ const fr = { addSort: "Ajouter un tri", removeSort: "Supprimer le tri", }, + collaboration: { + commentCount: "{{count}} commentaires", + commentCountOne: "{{count}} commentaire", + resolvedSuffix: " · Résolu", + sortComments: "Trier les commentaires", + sortOldest: "Plus anciens", + sortNewest: "Plus récents", + resolve: "Résoudre", + reopen: "Rouvrir", + justNow: "à l'instant", + minutesAgo: "il y a {{count}} min", + hoursAgo: "il y a {{count}} h", + daysAgo: "il y a {{count}} j", + edited: "(modifié)", + reactionCount: "{{count}} réactions", + reactionCountOne: "{{count}} réaction", + addThumbsUp: "Ajouter un pouce levé", + reply: "Répondre", + replyingTo: "Réponse à {{name}}…", + replyingToComment: "Réponse au commentaire…", + commentPlaceholder: "Ajouter un commentaire… (utilisez @ pour mentionner)", + send: "Envoyer", + }, }; export default fr; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index c7afb037d6..e704091428 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3051,6 +3051,29 @@ const ja = { addSort: "並べ替えを追加", removeSort: "並べ替えを削除", }, + collaboration: { + commentCount: "コメント {{count}} 件", + commentCountOne: "コメント {{count}} 件", + resolvedSuffix: " · 解決済み", + sortComments: "コメントの並べ替え", + sortOldest: "古い順", + sortNewest: "新しい順", + resolve: "解決済みにする", + reopen: "再オープン", + justNow: "たった今", + minutesAgo: "{{count}} 分前", + hoursAgo: "{{count}} 時間前", + daysAgo: "{{count}} 日前", + edited: "(編集済み)", + reactionCount: "リアクション {{count}} 件", + reactionCountOne: "リアクション {{count}} 件", + addThumbsUp: "いいねを追加", + reply: "返信", + replyingTo: "{{name}} に返信中…", + replyingToComment: "このコメントに返信中…", + commentPlaceholder: "コメントを追加…(@ でメンション)", + send: "送信", + }, }; export default ja; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 6b334b27a6..3cf5f1f6a2 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3051,6 +3051,29 @@ const ko = { addSort: "정렬 추가", removeSort: "정렬 삭제", }, + collaboration: { + commentCount: "댓글 {{count}}개", + commentCountOne: "댓글 {{count}}개", + resolvedSuffix: " · 해결됨", + sortComments: "댓글 정렬", + sortOldest: "오래된 순", + sortNewest: "최신 순", + resolve: "해결됨으로 표시", + reopen: "다시 열기", + justNow: "방금", + minutesAgo: "{{count}}분 전", + hoursAgo: "{{count}}시간 전", + daysAgo: "{{count}}일 전", + edited: "(수정됨)", + reactionCount: "반응 {{count}}개", + reactionCountOne: "반응 {{count}}개", + addThumbsUp: "좋아요 추가", + reply: "답글", + replyingTo: "{{name}}님에게 답글 작성 중…", + replyingToComment: "이 댓글에 답글 작성 중…", + commentPlaceholder: "댓글 추가…(@로 멘션)", + send: "보내기", + }, }; export default ko; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 250de516b7..cd0cd1b04a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3051,6 +3051,29 @@ const pt = { addSort: "Adicionar ordenação", removeSort: "Remover ordenação", }, + collaboration: { + commentCount: "{{count}} comentários", + commentCountOne: "{{count}} comentário", + resolvedSuffix: " · Resolvido", + sortComments: "Ordenar comentários", + sortOldest: "Mais antigos", + sortNewest: "Mais recentes", + resolve: "Resolver", + reopen: "Reabrir", + justNow: "agora mesmo", + minutesAgo: "há {{count}} min", + hoursAgo: "há {{count}} h", + daysAgo: "há {{count}} d", + edited: "(editado)", + reactionCount: "{{count}} reações", + reactionCountOne: "{{count}} reação", + addThumbsUp: "Adicionar curtida", + reply: "Responder", + replyingTo: "Respondendo a {{name}}…", + replyingToComment: "Respondendo ao comentário…", + commentPlaceholder: "Adicionar um comentário… (use @ para mencionar)", + send: "Enviar", + }, }; export default pt; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 4a9a161740..5357da2c1d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3051,6 +3051,29 @@ const ru = { addSort: "Добавить сортировку", removeSort: "Удалить сортировку", }, + collaboration: { + commentCount: "Комментариев: {{count}}", + commentCountOne: "{{count}} комментарий", + resolvedSuffix: " · Решено", + sortComments: "Сортировка комментариев", + sortOldest: "Сначала старые", + sortNewest: "Сначала новые", + resolve: "Решить", + reopen: "Открыть заново", + justNow: "только что", + minutesAgo: "{{count}} мин назад", + hoursAgo: "{{count}} ч назад", + daysAgo: "{{count}} д назад", + edited: "(изменено)", + reactionCount: "Реакций: {{count}}", + reactionCountOne: "{{count}} реакция", + addThumbsUp: "Поставить лайк", + reply: "Ответить", + replyingTo: "Ответ для {{name}}…", + replyingToComment: "Ответ на комментарий…", + commentPlaceholder: "Добавьте комментарий… (@ — упоминание)", + send: "Отправить", + }, }; export default ru; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 45048171f3..4a5b5c7db2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3105,6 +3105,29 @@ const zh = { addSort: '添加排序', removeSort: '删除排序', }, + collaboration: { + commentCount: '{{count}} 条评论', + commentCountOne: '{{count}} 条评论', + resolvedSuffix: ' · 已解决', + sortComments: '评论排序', + sortOldest: '最早优先', + sortNewest: '最新优先', + resolve: '标记已解决', + reopen: '重新打开', + justNow: '刚刚', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + daysAgo: '{{count}} 天前', + edited: '(已编辑)', + reactionCount: '{{count}} 个回应', + reactionCountOne: '{{count}} 个回应', + addThumbsUp: '点赞', + reply: '回复', + replyingTo: '正在回复 {{name}}…', + replyingToComment: '正在回复该评论…', + commentPlaceholder: '添加评论…(输入 @ 提及他人)', + send: '发送', + }, } as const; export default zh; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1df4e0081..8ae1e4bb5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -870,6 +870,9 @@ importers: packages/collaboration: dependencies: + '@object-ui/i18n': + specifier: workspace:* + version: link:../i18n '@object-ui/types': specifier: workspace:* version: link:../types