fix(i18n): reaction tooltip word order and plural agreement (#2438)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 22:11:59 +02:00
1 parent d7d4e8da03
commit b0645300ec
38 files changed
+4041 -3446

No files matched your search

@@ -14,8 +14,9 @@ import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {msg, plural} from '@lingui/core/macro';
const REACTED_BY_DESCRIPTOR = msg({
message: '{emojiName} reacted by {reactors}',
comment: 'Label in the reaction utils helper. Preserve {emojiName}, {reactors}; they are inserted by code.',
message: '{reactorCount, plural, one {{emojiName} reacted by {reactors}} other {{emojiName} reacted by {reactors}}}',
comment:
'Reaction tooltip. Preserve {emojiName}, {reactors}; they are inserted by code. {reactorCount} is how many people reacted, for verb agreement. Order the parts so the emoji never reads as the one doing the reacting.',
});
const listFormatterCache = new Map<string, Intl.ListFormat>();
@@ -44,14 +45,15 @@ export function getReactionTooltip(message: Message, emoji: ReactionEmoji) {
}
const reaction = message.getReaction(emoji);
const reactionCount = reaction === null || reaction === undefined ? 0 : reaction.count;
const othersCount = Math.max(0, (reactionCount || 0) - users.length);
const reactorCount = Math.max(users.length, reactionCount);
const othersCount = Math.max(0, reactorCount - users.length);
const emojiName = getEmojiNameWithColons(emoji);
const parts: Array<string> = [...users];
if (othersCount > 0) {
parts.push(plural({count: othersCount}, {one: '# other', other: '# others'}));
}
const reactors = getReactorListFormatter(getCurrentLocale()).format(parts);
return i18n._(REACTED_BY_DESCRIPTOR, {emojiName, reactors});
return i18n._(REACTED_BY_DESCRIPTOR, {emojiName, reactors, reactorCount});
}
const isCustomEmoji = (emoji: UnicodeEmoji | ReactionEmoji): emoji is ReactionEmoji =>
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readdirSync, readFileSync} from 'node:fs';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {i18n} from '@lingui/core';
import {describe, expect, it} from 'vitest';
const REACTED_BY_ID =
'{reactorCount, plural, one {{emojiName} reacted by {reactors}} other {{emojiName} reacted by {reactors}}}';
const localesDir = join(dirname(fileURLToPath(import.meta.url)), '../../i18n/locales');
function readTranslation(locale: string): string {
const source = readFileSync(join(localesDir, locale, 'messages.po'), 'utf8');
const escaped = REACTED_BY_ID.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = source.match(new RegExp(`^msgid "${escaped}"\\nmsgstr ((?:"(?:[^"\\\\]|\\\\.)*"\\n?)+)`, 'm'));
if (!match?.[1]) {
throw new Error(`missing reaction tooltip translation for ${locale}`);
}
return match[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('"'))
.map((line) => JSON.parse(line) as string)
.join('');
}
function render(locale: string, reactors: string, reactorCount: number): string {
i18n.load(locale, {[REACTED_BY_ID]: readTranslation(locale)});
i18n.activate(locale);
return i18n._({id: REACTED_BY_ID, message: REACTED_BY_ID}, {emojiName: ':100:', reactors, reactorCount});
}
const locales = readdirSync(localesDir, {withFileTypes: true})
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
describe('reaction tooltip catalog', () => {
it('ships a translation for every locale', () => {
expect(locales.length).toBeGreaterThan(30);
for (const locale of locales) {
expect(readTranslation(locale).length).toBeGreaterThan(0);
}
});
it('keeps both placeholders in every plural branch of every locale', () => {
for (const locale of locales) {
for (const count of [1, 5]) {
const output = render(locale, 'alex', count);
expect(output, `${locale} @ ${count}`).toContain(':100:');
expect(output, `${locale} @ ${count}`).toContain('alex');
expect(output, `${locale} @ ${count}`).not.toContain('{');
}
}
});
it('agrees with the reactor count in French, the locale the bug was reported in', () => {
expect(render('fr', 'test', 1)).toBe('test a réagi avec :100:');
expect(render('fr', 'test, alex et 3 autres', 5)).toBe('test, alex et 3 autres ont réagi avec :100:');
});
it('never makes the emoji the subject of an active verb in the reported locales', () => {
expect(render('es-ES', 'test', 1)).toBe('test ha reaccionado con :100:');
expect(render('es-ES', 'test y alex', 2)).toBe('test y alex han reaccionado con :100:');
expect(render('nl', 'test', 1)).toBe('test reageerde met :100:');
expect(render('nl', 'test en alex', 2)).toBe('test en alex reageerden met :100:');
expect(render('pt-BR', 'test', 1)).toBe('test reagiu com :100:');
expect(render('de', 'test', 1)).toBe(':100: Reaktion von test');
});
it('leaves English as the emoji-first phrasing the design intends', () => {
expect(render('en-US', 'test', 1)).toBe(':100: reacted by test');
expect(render('en-GB', 'test and alex', 2)).toBe(':100: reacted by test and alex');
});
});
@@ -15,8 +15,9 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {msg, plural} from '@lingui/core/macro';
const REACTED_BY_DESCRIPTOR = msg({
message: '{emojiName} reacted by {reactors}',
comment: 'Label in the reaction utils helper. Preserve {emojiName}, {reactors}; they are inserted by code.',
message: '{reactorCount, plural, one {{emojiName} reacted by {reactors}} other {{emojiName} reacted by {reactors}}}',
comment:
'Reaction tooltip. Preserve {emojiName}, {reactors}; they are inserted by code. {reactorCount} is how many people reacted, for verb agreement. Order the parts so the emoji never reads as the one doing the reacting.',
});
const listFormatterCache = new Map<string, Intl.ListFormat>();
@@ -45,14 +46,15 @@ export function getReactionTooltip(message: Message, emoji: ReactionEmoji) {
return '';
}
const reaction = message.getReaction(emoji);
const othersCount = Math.max(0, (reaction?.count || 0) - users.length);
const reactorCount = Math.max(users.length, reaction?.count ?? 0);
const othersCount = Math.max(0, reactorCount - users.length);
const emojiName = getEmojiNameWithColons(emoji);
const parts: Array<string> = [...users];
if (othersCount > 0) {
parts.push(plural({count: othersCount}, {one: '# other', other: '# others'}));
}
const reactors = getReactorListFormatter(getCurrentLocale()).format(parts);
return i18n._(REACTED_BY_DESCRIPTOR, {emojiName, reactors});
return i18n._(REACTED_BY_DESCRIPTOR, {emojiName, reactors, reactorCount});
}
const isCustomEmoji = (emoji: UnicodeEmoji | ReactionEmoji): emoji is ReactionEmoji =>