fix: scope separate translation hooks instead of relative namespace paths

next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.

Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.

The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
This commit is contained in:
zisisvakras
2026-08-12 17:53:35 +03:00
parent 6d827e430f
commit 07ce2d0139
4 changed files with 17 additions and 14 deletions
+9 -7
View File
@@ -83,6 +83,8 @@ export function EmailList({
onRescheduleScheduled,
}: EmailListProps) {
const t = useTranslations('email_list');
const tContextMenu = useTranslations('context_menu');
const tSpam = useTranslations('email_viewer.spam');
const { client } = useAuthStore();
const {
selectedEmailIds,
@@ -211,10 +213,10 @@ export function EmailList({
const emailIds = Array.from(selectedEmailIds);
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length }));
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
toast.error(tSpam('error_not_spam'));
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
@@ -392,7 +394,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={handleBatchUndoSpam}
title={t('../context_menu.not_spam')}
title={tContextMenu('not_spam')}
disabled={isProcessing}
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
>
@@ -629,11 +631,11 @@ export function EmailList({
await batchMarkAsSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_batch', { count: emailIds.length })
tSpam('toast_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error'));
toast.error(tSpam('error'));
}
}
}}
@@ -644,11 +646,11 @@ export function EmailList({
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length })
tSpam('toast_not_spam_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
toast.error(tSpam('error_not_spam'));
}
}
}}
+4 -3
View File
@@ -51,6 +51,7 @@ function VersionUpdateTag() {
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const tSettings = useTranslations('settings');
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
@@ -99,9 +100,9 @@ export function AboutDataSettings() {
const json = event.target?.result as string;
const success = importSettings(json);
if (success) {
alert(t('../../settings.import_success'));
alert(tSettings('import_success'));
} else {
alert(t('../../settings.import_error'));
alert(tSettings('import_error'));
}
};
reader.readAsText(file);
@@ -120,7 +121,7 @@ export function AboutDataSettings() {
if (showResetConfirm) {
resetToDefaults();
setShowResetConfirm(false);
alert(t('../../settings.save_success'));
alert(tSettings('save_success'));
} else {
setShowResetConfirm(true);
setTimeout(() => setShowResetConfirm(false), 5000);
+4 -3
View File
@@ -31,6 +31,7 @@ function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
export function AccountSettings() {
const t = useTranslations('settings.account');
const tCommon = useTranslations('common');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -117,12 +118,12 @@ export function AccountSettings() {
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
<span className="text-sm text-foreground">{displayName || tCommon('unknown')}</span>
</SettingItem>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
<span className="text-sm text-foreground">{email || tCommon('unknown')}</span>
</SettingItem>
{/* Username / Login (show when it differs from email) */}
@@ -142,7 +143,7 @@ export function AccountSettings() {
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
{serverUrl || tCommon('unknown')}
</span>
</SettingItem>
-1
View File
@@ -75,7 +75,6 @@ function extractUsedKeys(filePath: string): string[] {
const callRegex = new RegExp(`\\b${varName}\\(\\s*["']([^"'{}]+)["']`, 'g');
while ((m = callRegex.exec(content)) !== null) {
const key = m[1];
if (key.startsWith('.')) continue;
// Find the nearest preceding assignment for this variable
const ns = varAssignments
.filter((a) => a.index < m!.index)