FIX - complete phone locale fallback

Resolve configured and regional locales through a centralized English fallback for Lua and NUI consumers. Complete the German and English app translation keys, localize remaining picker and attachment copy, and add contract tests that detect missing locale entries.
This commit is contained in:
Leon.Schmidt
2026-08-17 15:01:57 +02:00
parent 9adc5a755f
commit e58f1b6c18
15 changed files with 438 additions and 75 deletions
+19 -8
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
import deEmojiData from 'emoji-picker-element-data/de/cldr/data.json'
import enEmojiData from 'emoji-picker-element-data/en/cldr/data.json'
import { Search, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'
@@ -25,10 +26,13 @@ const activeGroup = ref(0)
const skinTone = ref(0)
const groups = ['😀', '👋', '🧑', '🐻', '🍔', '⚽', '🚗', '💡', '❤️', '🏳️']
const skinTones = ['✋', '✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿']
const localizedEmojiData = computed(() =>
phone.lang.toLocaleLowerCase().startsWith('de') ? deEmojiData : enEmojiData,
)
const emojis = computed(() => {
const needle = query.value.trim().toLocaleLowerCase(phone.lang)
return (emojiData as EmojiEntry[])
return (localizedEmojiData.value as EmojiEntry[])
.filter((entry) => {
if (!needle) return entry.group === activeGroup.value
return `${entry.annotation} ${entry.shortcodes?.join(' ') ?? ''} ${entry.tags?.join(' ') ?? ''}`
@@ -40,17 +44,24 @@ const emojis = computed(() => {
emoji:
skinTone.value === 0
? entry.emoji
: entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
entry.emoji,
: (entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
entry.emoji),
}))
})
</script>
<template>
<section class="messages-full-emoji-picker" aria-label="Emoji picker">
<section
class="messages-full-emoji-picker"
:aria-label="phone.t('Apps.messages.emojiPicker')"
>
<header>
<strong>{{ phone.t('Apps.messages.emoji') }}</strong>
<button type="button" :aria-label="phone.t('Common.done')" @click="emit('close')">
<button
type="button"
:aria-label="phone.t('Common.done')"
@click="emit('close')"
>
<X :size="18" />
</button>
</header>
@@ -63,7 +74,7 @@ const emojis = computed(() => {
autocomplete="off"
/>
</label>
<nav v-if="!query" aria-label="Emoji categories">
<nav v-if="!query" :aria-label="phone.t('Apps.messages.emojiCategories')">
<button
v-for="(group, index) in groups"
:key="group"
@@ -79,7 +90,7 @@ const emojis = computed(() => {
:key="tone"
type="button"
:class="{ active: skinTone === index }"
:title="`Skin tone ${index}`"
:title="phone.t('Apps.messages.skinTone', { number: String(index) })"
@click="skinTone = index"
>
{{ tone }}
@@ -32,12 +32,12 @@ const videoStyles: Record<string, string> = {
'ocean-loop': 'linear-gradient(145deg, #0b132b, #26648e 55%, #67d5b5)',
'sunset-loop': 'linear-gradient(145deg, #141e30, #5f2c82 55%, #ff9a62)',
}
const gifContent: Record<string, { emoji: string; label: string }> = {
celebrate: { emoji: '🎉', label: 'Celebrate!' },
hearts: { emoji: '💖', label: 'Love it' },
party: { emoji: '🥳', label: 'Party time' },
thumbs_up: { emoji: '👍', label: 'Perfect' },
wow: { emoji: '🤯', label: 'WOW' },
const gifContent: Record<string, { emoji: string; key: string }> = {
celebrate: { emoji: '🎉', key: 'celebrate' },
hearts: { emoji: '💖', key: 'hearts' },
party: { emoji: '🥳', key: 'party' },
thumbs_up: { emoji: '👍', key: 'thumbsUp' },
wow: { emoji: '🤯', key: 'wow' },
}
const background = computed(() =>
@@ -139,13 +139,13 @@ function durationLabel(milliseconds: number | null): string {
<img
v-if="mediaUrl"
:src="mediaUrl"
alt="GIF"
:alt="phone.t('Apps.messages.gif')"
loading="lazy"
referrerpolicy="no-referrer"
/>
<template v-else>
<span>{{ gif.emoji }}</span>
<strong>{{ gif.label }}</strong>
<strong>{{ phone.t(`Apps.messages.gifLabels.${gif.key}`) }}</strong>
</template>
</div>
<p v-if="message.body?.trim()" class="messages-attachment-caption">
@@ -0,0 +1,232 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
type LuaToken = {
kind: string
value: string
}
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
const englishLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
)
const germanLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
'utf8',
)
const phoneStoreSource = readFileSync(
new URL('./phone.ts', import.meta.url),
'utf8',
)
function tokenizeLua(source: string): LuaToken[] {
const tokens: LuaToken[] = []
let index = 0
while (index < source.length) {
const character = source[index]
if (/\s/.test(character)) {
index += 1
continue
}
if (source.startsWith('--', index)) {
const nextLine = source.indexOf('\n', index)
if (nextLine < 0) break
index = nextLine
continue
}
if ('{}[]=,;'.includes(character)) {
tokens.push({ kind: character, value: character })
index += 1
continue
}
if (character === '"' || character === "'") {
const quote = character
let value = ''
index += 1
while (index < source.length && source[index] !== quote) {
if (source[index] === '\\' && index + 1 < source.length) {
value += source[index + 1]
index += 2
} else {
value += source[index]
index += 1
}
}
tokens.push({ kind: 'string', value })
index += 1
continue
}
const match = source
.slice(index)
.match(/^[A-Za-z_][A-Za-z0-9_]*|^-?\d+(?:\.\d+)?/)
if (match) {
tokens.push({ kind: 'word', value: match[0] })
index += match[0].length
continue
}
index += 1
}
return tokens
}
function collectLuaLocalePaths(source: string): Set<string> {
const tokens = tokenizeLua(source)
let position =
tokens.findIndex(
(token, index) => token.kind === '=' && tokens[index + 1]?.kind === '{',
) + 1
const paths = new Set<string>()
function parseValue(path: string[]): void {
if (tokens[position]?.kind === '{') {
parseTable(path)
return
}
if (path.length) paths.add(path.join('.'))
position += 1
}
function parseTable(path: string[]): void {
position += 1
while (position < tokens.length && tokens[position].kind !== '}') {
let key: string | null = null
if (
tokens[position].kind === 'word' &&
tokens[position + 1]?.kind === '='
) {
key = tokens[position].value
position += 2
} else if (
tokens[position].kind === '[' &&
tokens[position + 1]?.kind === 'string' &&
tokens[position + 2]?.kind === ']' &&
tokens[position + 3]?.kind === '='
) {
key = tokens[position + 1].value
position += 4
}
parseValue(key === null ? [] : [...path, key])
while (tokens[position]?.kind === ',' || tokens[position]?.kind === ';') {
position += 1
}
}
position += 1
}
parseTable([])
return paths
}
function collectDefaultLocalePaths(source: string): Set<string> {
const ast = ts.createSourceFile(
'phone.ts',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
)
const declarations = new Map<string, ts.Expression>()
const registerDeclarations = (node: ts.Node): void => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer
) {
declarations.set(node.name.text, node.initializer)
}
ts.forEachChild(node, registerDeclarations)
}
registerDeclarations(ast)
const paths = new Set<string>()
const collect = (node: ts.Expression, path: string[]): void => {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
collect(node.expression, path)
return
}
if (ts.isIdentifier(node) && declarations.has(node.text)) {
collect(declarations.get(node.text)!, path)
return
}
if (!ts.isObjectLiteralExpression(node)) {
if (path.length) paths.add(path.join('.'))
return
}
for (const property of node.properties) {
if (ts.isSpreadAssignment(property)) {
collect(property.expression, path)
continue
}
if (!ts.isPropertyAssignment(property)) continue
const name = property.name
const key =
ts.isIdentifier(name) ||
ts.isStringLiteral(name) ||
ts.isNumericLiteral(name)
? name.text
: null
if (key !== null) collect(property.initializer, [...path, key])
}
}
const defaultLocales = declarations.get('defaultLocales')
expect(defaultLocales).toBeDefined()
collect(defaultLocales!, [])
return paths
}
function collectFrontendFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
return entry.name === 'development' ? [] : collectFrontendFiles(path)
}
return /\.(?:ts|vue)$/.test(entry.name) && !entry.name.includes('.test.')
? [path]
: []
})
}
describe('phone locale contract', () => {
const englishPaths = collectLuaLocalePaths(englishLocaleSource)
it('keeps every bundled frontend fallback in en.lua', () => {
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
(path) => !englishPaths.has(`Nui.${path}`),
)
expect(missing).toEqual([])
})
it('defines every static frontend translation key in en.lua', () => {
const missing: string[] = []
for (const file of collectFrontendFiles(frontendSourceDirectory)) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(
/phone\.t\(\s*(['"])([^'"`]+)\1\s*[,)]/g,
)) {
if (!englishPaths.has(`Nui.${match[2]}`)) {
missing.push(
`${relative(frontendSourceDirectory, file)}: ${match[2]}`,
)
}
}
}
expect(missing).toEqual([])
})
it('builds German on the complete English locale fallback', () => {
expect(germanLocaleSource).toContain(
'local german = translate_shared(clone(Locales["en"] or {}))',
)
})
})
+18 -3
View File
@@ -51,9 +51,7 @@ describe('phone locale fallback', () => {
expect(phone.t('Apps.radio.providerFeatureUnavailable')).toBe(
'Not supported by this voice service',
)
expect(phone.t('Apps.radio.displayNamePlaceholder')).toBe(
'Character name',
)
expect(phone.t('Apps.radio.displayNamePlaceholder')).toBe('Character name')
})
it('keeps Messages media controls translated with a partial server locale', () => {
@@ -71,4 +69,21 @@ describe('phone locale fallback', () => {
)
expect(phone.t('Apps.messages.seekAudio')).toBe('Seek Audio')
})
it('uses the English Lua payload before the bundled emergency fallback', () => {
const phone = usePhoneStore()
phone.open({
fallbackLocales: {
Common: {
cancel: 'Cancel from en.lua',
save: 'Save from en.lua',
},
},
locales: { Common: { save: 'Speichern' } },
})
expect(phone.t('Common.save')).toBe('Speichern')
expect(phone.t('Common.cancel')).toBe('Cancel from en.lua')
expect(phone.t('Common.close')).toBe('Close')
})
})
+22 -5
View File
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
fallbackLocales?: LocaleTree
lang?: string
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
@@ -1887,6 +1888,16 @@ const defaultLocales: LocaleTree = {
contactDeleteFailed: 'The contact could not be deleted.',
callFailed: 'The call could not be started.',
emoji: 'Emoji',
emojiPicker: 'Emoji picker',
emojiCategories: 'Emoji categories',
skinTone: 'Skin tone {number}',
gifLabels: {
celebrate: 'Celebrate!',
hearts: 'Love it',
party: 'Party time',
thumbsUp: 'Perfect',
wow: 'WOW',
},
voiceMessage: 'Audio Message',
recordVoice: 'Record Audio',
playAudio: 'Play Audio',
@@ -2127,6 +2138,7 @@ const defaultLocales: LocaleTree = {
takePhoto: 'Camera',
removePhoto: 'Remove Photo',
call: 'Call',
callFailed: 'The call could not be started.',
message: 'Message',
video: 'Video',
mail: 'Mail',
@@ -4671,6 +4683,7 @@ export const usePhoneStore = defineStore('phone', {
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
fallbackLocales: defaultLocales,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
persistenceGeneration: 0,
@@ -4709,7 +4722,8 @@ export const usePhoneStore = defineStore('phone', {
}
this.deviceSessionToken = nextToken
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
this.locales = payload.locales ?? this.fallbackLocales
if (payload.device) this.hydrateDevice(payload.device)
if (payload.player) this.player = payload.player
this.security = payload.security ?? {
@@ -4941,13 +4955,16 @@ export const usePhoneStore = defineStore('phone', {
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
const english = getByPath(this.fallbackLocales, path)
const emergencyFallback = getByPath(defaultLocales, path)
const value =
typeof translated === 'string'
? translated
: typeof fallback === 'string'
? fallback
: path
: typeof english === 'string'
? english
: typeof emergencyFallback === 'string'
? emergencyFallback
: path
return Object.entries(replacements).reduce(
(result, [key, replacement]) =>
result.split(`{${key}}`).join(replacement),