= {
+ 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 {
{{ gif.emoji }}
- {{ gif.label }}
+ {{ phone.t(`Apps.messages.gifLabels.${gif.key}`) }}
diff --git a/frontend/src/stores/phone-locale-contract.test.ts b/frontend/src/stores/phone-locale-contract.test.ts
new file mode 100644
index 0000000..bc7389c
--- /dev/null
+++ b/frontend/src/stores/phone-locale-contract.test.ts
@@ -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 {
+ const tokens = tokenizeLua(source)
+ let position =
+ tokens.findIndex(
+ (token, index) => token.kind === '=' && tokens[index + 1]?.kind === '{',
+ ) + 1
+ const paths = new Set()
+
+ 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 {
+ const ast = ts.createSourceFile(
+ 'phone.ts',
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ )
+ const declarations = new Map()
+ 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()
+ 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 {}))',
+ )
+ })
+})
diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts
index 38de1d4..5b061be 100644
--- a/frontend/src/stores/phone-locales.test.ts
+++ b/frontend/src/stores/phone-locales.test.ts
@@ -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')
+ })
})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 51c4c03..c6fa122 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -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 {
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),
diff --git a/sky_phone/config/init.lua b/sky_phone/config/init.lua
index e4ee603..1500f9c 100644
--- a/sky_phone/config/init.lua
+++ b/sky_phone/config/init.lua
@@ -1,3 +1,47 @@
Config = Config or {}
Locales = Locales or {}
+SkyPhoneLocales = SkyPhoneLocales or {}
+
+local resolved_locales = {}
+
+local function clone_locale(value)
+ if type(value) ~= "table" then
+ return value
+ end
+
+ local copied = {}
+ for key, nested in pairs(value) do
+ copied[key] = clone_locale(nested)
+ end
+ return copied
+end
+
+local function merge_locale(target, values)
+ for key, value in pairs(values) do
+ if type(value) == "table" and type(target[key]) == "table" then
+ merge_locale(target[key], value)
+ else
+ target[key] = clone_locale(value)
+ end
+ end
+end
+
+function SkyPhoneLocales.Resolve(requested_locale)
+ local normalized = type(requested_locale) == "string" and requested_locale:lower():gsub("_", "-") or "en"
+ local base = normalized:match("^([^-]+)") or "en"
+ local locale_name = Locales[normalized] and normalized or (Locales[base] and base or "en")
+ local selected = Locales[locale_name] or Locales.en or {}
+ local english = Locales.en or {}
+
+ if selected == english then
+ return english, "en"
+ end
+
+ if not resolved_locales[locale_name] then
+ resolved_locales[locale_name] = clone_locale(english)
+ merge_locale(resolved_locales[locale_name], selected)
+ end
+
+ return resolved_locales[locale_name], locale_name
+end
diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua
index 3e93232..b4a5990 100644
--- a/sky_phone/config/locales/de.lua
+++ b/sky_phone/config/locales/de.lua
@@ -187,6 +187,44 @@ local translations = {
["No data available"] = "Keine Daten verfügbar",
["Required"] = "Erforderlich",
["Optional"] = "Optional",
+ ["Return to Call"] = "Zurück zum Anruf",
+ ["This item is not available for EasyShare."] = "Dieses Element kann nicht über EasyShare geteilt werden.",
+ ["The configured GIPHY API key is invalid."] = "Der konfigurierte GIPHY-API-Schlüssel ist ungültig.",
+ ["GIPHY is busy. Try again in a moment."] = "GIPHY ist ausgelastet. Versuche es gleich erneut.",
+ ["Search Calls"] = "Anrufe suchen",
+ ["Contact Details"] = "Kontaktdetails",
+ ["Unknown Caller"] = "Unbekannter Anrufer",
+ ["Call History"] = "Anrufverlauf",
+ ["No calls with this number yet."] = "Noch keine Anrufe mit dieser Nummer.",
+ ["My Card"] = "Meine Karte",
+ ["My Number"] = "Meine Nummer",
+ ["Device"] = "Gerät",
+ ["Contact Index"] = "Kontaktindex",
+ ["The call could not be started."] = "Der Anruf konnte nicht gestartet werden.",
+ ["Los Santos marketplace"] = "Marktplatz von Los Santos",
+ ["Choose up to six photos from Photos or take new ones. Photos are optional and the first becomes the cover."] = "Wähle bis zu sechs Fotos aus Fotos oder nimm neue auf. Fotos sind optional; das erste wird zum Titelbild.",
+ ["Use photos saved on this phone."] = "Verwende Fotos, die auf diesem Handy gespeichert sind.",
+ ["Take photos"] = "Fotos aufnehmen",
+ ["Take several new photos for this listing."] = "Nimm mehrere neue Fotos für dieses Angebot auf.",
+ ["Selected photos"] = "Ausgewählte Fotos",
+ ["Take as many photos as you need. Each shot is added to the listing automatically."] = "Nimm so viele Fotos auf, wie du benötigst. Jede Aufnahme wird automatisch zum Angebot hinzugefügt.",
+ ["No photo available"] = "Kein Foto verfügbar",
+ ["The seller did not add a photo to this listing."] = "Der Verkäufer hat diesem Angebot kein Foto hinzugefügt.",
+ ["You can publish without a photo or add one from the gallery or camera."] = "Du kannst ohne Foto veröffentlichen oder eines aus der Galerie beziehungsweise Kamera hinzufügen.",
+ ["Previous photo"] = "Vorheriges Foto",
+ ["Next photo"] = "Nächstes Foto",
+ ["Remove photo {number}"] = "Foto {number} entfernen",
+ ["You can add up to six photos."] = "Du kannst bis zu sechs Fotos hinzufügen.",
+ ["{current} / {maximum} characters"] = "{current} / {maximum} Zeichen",
+ ["min. {minimum}"] = "mind. {minimum}",
+ ["Emoji picker"] = "Emoji-Auswahl",
+ ["Emoji categories"] = "Emoji-Kategorien",
+ ["Skin tone {number}"] = "Hautton {number}",
+ ["Celebrate!"] = "Feiern!",
+ ["Love it"] = "Gefällt mir",
+ ["Party time"] = "Partyzeit",
+ ["Perfect"] = "Perfekt",
+ ["WOW"] = "WOW",
}
local function translate_shared(value)
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index f97b921..29a4163 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -608,7 +608,7 @@ Locales["en"] = {
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
messageDeleted = "Message deleted", securityUpdate = "Security settings updated", timerChanged = "Disappearing messages: {timer}",
timerOff = "Off", timerAfterRead = "After reading", timerMinute = "1 minute", timerFiveMinutes = "5 minutes", timerHour = "1 hour", timerDay = "24 hours", timerWeek = "7 days",
- copied = "Copied", reply = "Reply", copy = "Copy", deleteForMe = "Delete for me", deleteForBoth = "Delete for both", report = "Report",
+ copied = "Copied", reply = "Reply", copy = "Copy", deleteForMe = "Delete for me", deleteForBoth = "Delete for both", report = "Report", returnToCall = "Return to Call",
contactSecurity = "Contact & Security", chatSince = "Private chat since {date}", notifications = "Notifications", readReceipts = "Read receipts", disappearing = "Disappearing messages", contactAlias = "Contact alias", saveContact = "Save Contact", addContact = "Add Contact", contactSaved = "Contact saved", removeContact = "Remove Contact", block = "Block User", unblock = "Unblock User", clearChat = "Clear Chat", chatCleared = "Chat cleared",
myIdentity = "My Dark Identity", alias = "Alias", notificationPrivacy = "Notification privacy", notificationFull = "Full · alias and message", notificationPrivate = "Private · generic message", notificationHidden = "Invisible · badge only", shareActivity = "Share activity status", inviteCode = "Private invitation code", copyInvite = "Copy Invite", shareInvite = "Share Invite", privacyDisclaimer = "DarkChat stores messages on the server and does not claim end-to-end encryption.",
messageActions = "Message actions", chatSettings = "Chat Settings", contactActions = "Contact Actions", privacySettings = "Privacy Settings",
@@ -629,7 +629,7 @@ Locales["en"] = {
photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
contacts = "Contacts", shareContact = "Share Contact", noContactsToShare = "Save a contact first to share it here.", contactSaved = "Contact Saved",
- noPhotos = "Take a photo first to attach it here.", noVideos = "Record a video in Camera first.", searchGifs = "Search GIPHY", loadMore = "Load More",
+ noPhotos = "Take a photo first to attach it here.", noVideos = "Record a video in Camera first.", searchGifs = "Search GIPHY", loadMore = "Load More", retryGifs = "Try Again",
moreActions = "More Actions", inboxActions = "Conversation Actions", sortLabel = "Conversation Sort", sortNewest = "Newest First", sortOldest = "Oldest First", unreadConversation = "{name}, {count} unread messages",
attachmentPreview = "Selected attachments", attachmentLimit = "You can attach up to {count} photos or videos.", removeAttachment = "Remove attachment {number}",
contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number", company = "Company", contactActions = "Contact Actions",
@@ -638,7 +638,9 @@ Locales["en"] = {
selectedCount = "{count} Selected", deleteSelected = "Delete",
contactSaveFailed = "The contact could not be saved.", contactDeleteFailed = "The contact could not be deleted.",
callFailed = "The call could not be started.",
- emoji = "Emoji", voiceMessage = "Audio Message", recordVoice = "Record Audio",
+ 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", pauseAudio = "Pause Audio", seekAudio = "Seek Audio",
recording = "Recording", stopAndSend = "Stop and Send", cancelRecording = "Cancel Recording",
sending = "Sending...", delivered = "Delivered", notDelivered = "Not Delivered",
@@ -654,7 +656,8 @@ Locales["en"] = {
capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.",
video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.",
recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.",
- gif_provider_unconfigured = "GIF search is not configured.", gif_provider_failed = "GIF search is temporarily unavailable.",
+ gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The configured GIPHY API key is invalid.",
+ gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
@@ -971,7 +974,10 @@ Locales["en"] = {
phone = {
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
- noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
+ noRecents = "No Recent Calls", allCalls = "All", missedCalls = "Missed", searchRecents = "Search Calls",
+ contactDetails = "Contact Details", unknownCaller = "Unknown Caller", callHistory = "Call History",
+ noCallHistory = "No calls with this number yet.", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
+ myCard = "My Card", myNumber = "My Number", device = "Device", contactIndex = "Contact Index",
addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number",
officialContact = "Official company contact",
choosePhoto = "Choose Contact Photo", chooseGallery = "Photos", takePhoto = "Camera", removePhoto = "Remove Photo",
@@ -982,7 +988,7 @@ Locales["en"] = {
block = "Block", blockCaller = "Block Caller", blockCallerTitle = "Block this caller?",
blockCallerBody = "{number} will no longer be able to call this SIM.",
faceTime = "FaceTime", hideKeypad = "Hide Keypad", more = "More", mute = "Mute", speaker = "Speaker", viewContact = "View Contact",
- call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
+ call = "Call", callFailed = "The call could not be started.", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
answer = "Answer", decline = "Decline", hangup = "End", addToContacts = "Add to Contacts",
@@ -1501,7 +1507,7 @@ Locales["en"] = {
},
},
citymarkt = {
- name = "CityMarkt",
+ name = "CityMarkt", eyebrow = "Los Santos marketplace",
tabs = { discover = "Discover", search = "Search", sell = "Sell", inbox = "Inbox", profile = "Me" },
categories = {
vehicles = "Vehicles", property = "Property", electronics = "Electronics", clothing = "Clothing",
@@ -1544,8 +1550,13 @@ Locales["en"] = {
signInToMessage = "Sign in to your Sky Cloud account to send a message.",
edit = "Edit", makeActive = "Make active", markSold = "Mark sold", remove = "Remove",
createListing = "Create listing", editListing = "Edit listing", save = "Save", step = "Step {current} of {total}", next = "Next", previous = "Back", publish = "Publish",
- addPhotos = "Add photos", addPhotosBody = "Choose up to six photos. The first becomes the cover.",
+ addPhotos = "Add photos", addPhotosBody = "Choose up to six photos from Photos or take new ones. Photos are optional and the first becomes the cover.",
+ chooseGalleryBody = "Use photos saved on this phone.", takePhotos = "Take photos", takePhotosBody = "Take several new photos for this listing.",
+ selectedPhotos = "Selected photos", gallery = "Photos", camera = "Camera", cameraHint = "Take as many photos as you need. Each shot is added to the listing automatically.",
+ noPhoto = "No photo available", noPhotoBody = "The seller did not add a photo to this listing.", noPhotoOptional = "You can publish without a photo or add one from the gallery or camera.",
+ previousPhoto = "Previous photo", nextPhoto = "Next photo", photo = "Photo", removePhoto = "Remove photo {number}", photoLimit = "You can add up to six photos.",
describeOffer = "Describe your offer", title = "Title", description = "Description", category = "Category", condition = "Condition",
+ characterCount = "{current} / {maximum} characters", minimumCharacters = "min. {minimum}",
priceAndPlace = "Price and location", priceType = "Price type", price = "Price", district = "District",
showPhone = "Show my current phone number", preview = "Preview", published = "Your listing is live.",
writeMessage = "Write a message", reserveForBuyer = "Reserve for this buyer", statusChanged = "Listing updated.",
@@ -1741,7 +1752,7 @@ Locales["en"] = {
easyShare = {
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
- shareProfile = "Share Profile", chooseConversation = "Choose a conversation",
+ shareProfile = "Share Profile", share = "Share", chooseConversation = "Choose a conversation",
kinds = {
contact = "Contact", document = "Document", link = "Link", location = "Location", media = "Media",
note = "Note", photo = "Photo", playlist = "Playlist", post = "Post",
@@ -1761,7 +1772,7 @@ Locales["en"] = {
disabled = "EasyShare is disabled.", invalid_payload = "This item cannot be shared.",
invalid_target = "Choose a valid recipient.", target_unavailable = "That player is no longer available.",
transfer_not_found = "That transfer is no longer available.", too_far = "The recipient is too far away.",
- not_owned = "This item no longer belongs to this phone.", payload_too_large = "This item is too large to share.",
+ not_owned = "This item no longer belongs to this phone.", unsupported_payload = "This item is not available for EasyShare.", payload_too_large = "This item is too large to share.",
location_unavailable = "Your location is unavailable.", rate_limited = "Too many share requests. Try again shortly.",
copy_failed = "Could not copy this item.", request_failed = "EasyShare is temporarily unavailable.",
},
diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua
index cee1d5d..4e13485 100644
--- a/sky_phone/source/client/garage.lua
+++ b/sky_phone/source/client/garage.lua
@@ -45,10 +45,8 @@ local vehicle_mods = {
modLivery = 48,
}
-local function garage_locale()
- local locale = Locales[Config.Bridge.Locale] or Locales["en"]
- return locale.Nui.Apps.garage
-end
+local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
+local garage_locale = phone_locale.Nui.Apps.garage
local function normalized_plate(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
@@ -233,7 +231,7 @@ local function fail_valet(error_code, server_cancel)
current_valet.can_cancel = false
current_valet.error = error_code
send_valet_state()
- local locale = garage_locale()
+ local locale = garage_locale
Bridge.Framework.Notify(locale.name, locale.errors[error_code] or locale.errors.default, "error", 5000)
SetTimeout(8000, function()
if current_valet and current_valet.order_id == order_id then
@@ -429,7 +427,7 @@ local function run_valet_delivery(order)
current_valet.status = "delivered"
current_valet.can_cancel = false
send_valet_state()
- local locale = garage_locale()
+ local locale = garage_locale
Bridge.Framework.Notify(locale.name, locale.valetDelivered, "success", 5000)
local completed_order_id = current_valet.order_id
SetTimeout(12000, function()
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index a0c7b04..ae26fdd 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -297,9 +297,7 @@ local server_callbacks = {
"media:import:url",
}
-local function get_locale()
- return Locales[Config.Bridge.Locale] or Locales["en"]
-end
+local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function update_nui_focus()
local focus = SkyPhoneFocus.Resolve({
@@ -375,8 +373,9 @@ local function send_open_message()
end
local payload = device_payload
- payload.lang = Config.Bridge.Locale
- payload.locales = get_locale().Nui
+ payload.lang = locale_name
+ payload.locales = locale.Nui
+ payload.fallbackLocales = Locales.en.Nui
SkyPhoneApps.SendCatalog()
SendNUIMessage({
type = "app:open",
@@ -445,8 +444,8 @@ if Config.Phone.DevelopmentCommand then
end
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
- local locale = get_locale().TestData
- local message = success and locale.Success or locale.Failed
+ local test_data_locale = locale.TestData
+ local message = success and test_data_locale.Success or test_data_locale.Failed
if success and type(detail) == "string" and detail ~= "" then
message = message:gsub("{email}", detail)
end
@@ -750,7 +749,7 @@ RegisterNetEvent("sky_phone:device:error", function(error_code)
tostring(error_code),
{ always = true }
)
- local message = get_locale().DeviceErrors[error_code] or get_locale().DeviceErrors.default
+ local message = locale.DeviceErrors[error_code] or locale.DeviceErrors.default
Bridge.Framework.Notify("iFruit", message, "error", 5000)
end)
@@ -767,7 +766,7 @@ RegisterNetEvent("sky_phone:gallery:changed", function()
end)
RegisterNetEvent("sky_phone:mail:new", function(data)
- local mail_locale = get_locale().Nui.Apps.mail
+ local mail_locale = locale.Nui.Apps.mail
data.title = mail_locale.name
data.text = mail_locale.newMessage:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "mail:new", data = data })
@@ -782,7 +781,7 @@ RegisterNetEvent("sky_phone:companies:changed", function(data)
end)
RegisterNetEvent("sky_phone:companies:notification", function(data)
- local companies_locale = get_locale().Nui.Apps.companies
+ local companies_locale = locale.Nui.Apps.companies
data.title = companies_locale.name
data.text = companies_locale.notifications[data.kind]
or companies_locale.notifications.requestUpdated
@@ -798,7 +797,7 @@ RegisterNetEvent("sky_phone:fliptok:verification-changed", function(data)
end)
RegisterNetEvent("sky_phone:fliptok:new", function(data)
- local fliptok_locale = get_locale().Nui.Apps.fliptok
+ local fliptok_locale = locale.Nui.Apps.fliptok
local notification_text = fliptok_locale.notifications[data.kind] or fliptok_locale.notifications.default
data.title = fliptok_locale.name
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
@@ -814,7 +813,7 @@ RegisterNetEvent("sky_phone:picstagram:verification-changed", function(data)
end)
RegisterNetEvent("sky_phone:picstagram:new", function(data)
- local picstagram_locale = get_locale().Nui.Apps.picstagram
+ local picstagram_locale = locale.Nui.Apps.picstagram
local notification_text = picstagram_locale.notifications[data.kind] or picstagram_locale.notifications.default
data.title = picstagram_locale.name
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
@@ -822,7 +821,7 @@ RegisterNetEvent("sky_phone:picstagram:new", function(data)
end)
RegisterNetEvent("sky_phone:feather:new", function(data)
- local feather_locale = get_locale().Nui.Apps.feather
+ local feather_locale = locale.Nui.Apps.feather
local notification_text = feather_locale.notifications[data.kind] or feather_locale.notifications.default
data.title = feather_locale.name
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
@@ -830,7 +829,7 @@ RegisterNetEvent("sky_phone:feather:new", function(data)
end)
RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
- local marketplace_locale = get_locale().Nui.Apps.citymarkt
+ local marketplace_locale = locale.Nui.Apps.citymarkt
data.title = marketplace_locale.name
if data.kind == "offer" then
data.text = marketplace_locale.newOffer
@@ -851,21 +850,21 @@ RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
end)
RegisterNetEvent("sky_phone:calendar:reminder", function(data)
- local calendar_locale = get_locale().Nui.Apps.calendar
+ local calendar_locale = locale.Nui.Apps.calendar
data.title = calendar_locale.name
data.text = calendar_locale.reminder:gsub("{title}", tostring(data.eventTitle))
SendNUIMessage({ type = "calendar:reminder", data = data })
end)
RegisterNetEvent("sky_phone:flare:match", function(data)
- local flare_locale = get_locale().Nui.Apps.flare
+ local flare_locale = locale.Nui.Apps.flare
data.title = flare_locale.name
data.text = flare_locale.newMatchNotification:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "flare:new-match", data = data })
end)
RegisterNetEvent("sky_phone:flare:message", function(data)
- local flare_locale = get_locale().Nui.Apps.flare
+ local flare_locale = locale.Nui.Apps.flare
data.title = flare_locale.name
data.text = flare_locale.newMessageNotification:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "flare:new-message", data = data })
@@ -906,7 +905,7 @@ RegisterNetEvent("sky_phone:billing:changed", function()
end)
RegisterNetEvent("sky_phone:billing:new", function(data)
- local billing_locale = get_locale().Nui.Apps.billing
+ local billing_locale = locale.Nui.Apps.billing
data.title = billing_locale.name
data.text = billing_locale.notifications.newInvoice
:gsub("{issuer}", tostring(data.issuer))
@@ -919,7 +918,7 @@ RegisterNetEvent("sky_phone:crewlink:changed", function(data)
end)
RegisterNetEvent("sky_phone:crewlink:notification", function(data)
- local crewlink_locale = get_locale().Nui.Apps.crewlink
+ local crewlink_locale = locale.Nui.Apps.crewlink
local notification_text = crewlink_locale.notifications[data.kind]
or crewlink_locale.notifications.default
data.title = crewlink_locale.name
@@ -935,7 +934,7 @@ RegisterNetEvent("sky_phone:messages:changed", function(data)
end)
RegisterNetEvent("sky_phone:messages:new", function(data)
- local messages_locale = get_locale().Nui.Apps.messages
+ local messages_locale = locale.Nui.Apps.messages
data.title = messages_locale.name
data.text = messages_locale.newMessage:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "messages:new", data = data })
@@ -946,7 +945,7 @@ RegisterNetEvent("sky_phone:darkchat:changed", function(data)
end)
RegisterNetEvent("sky_phone:darkchat:new", function(data)
- local darkchat_locale = get_locale().Nui.Apps.darkchat
+ local darkchat_locale = locale.Nui.Apps.darkchat
data.title = darkchat_locale.name
if data.notificationMode == "private" then
data.sender = nil
@@ -1003,10 +1002,10 @@ end)
CreateThread(function()
if Config.Phone.DevelopmentCommand then
- TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
+ TriggerEvent("chat:addSuggestion", "/" .. Config.Command, locale.CommandDescription)
end
if Config.TestData.Enabled then
- TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, get_locale().TestData.CommandDescription)
+ TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
end
end)
diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua
index df2295f..74fadc1 100644
--- a/sky_phone/source/client/payphones.lua
+++ b/sky_phone/source/client/payphones.lua
@@ -83,9 +83,7 @@ RegisterNetEvent("sky_phone:payphone:visual:stop", function(data)
restore_remote_visual(data.id)
end)
-local function get_locale()
- return Locales[Config.Bridge.Locale] or Locales["en"]
-end
+local locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function load_model(model_hash)
if HasModelLoaded(model_hash) then
@@ -366,7 +364,7 @@ local function payphone_open_payload()
currency = Config.Payphones.Currency,
maxNumberLength = Config.Sim.NumberLength,
pricePerSecond = Config.Payphones.PricePerSecond,
- locales = get_locale().Nui.Payphone,
+ locales = locale.Nui.Payphone,
}
end
@@ -400,16 +398,16 @@ local function current_call_elapsed_seconds()
end
local function call_help_message()
- local locale = get_locale().Payphone
+ local payphone_locale = locale.Payphone
local message
if active_call_state == "connected" then
local elapsed_seconds = current_call_elapsed_seconds()
- message = locale.ConnectedHelp
+ message = payphone_locale.ConnectedHelp
message = replace_placeholder(message, "duration", format_duration(elapsed_seconds))
message = replace_placeholder(message, "currency", Config.Payphones.Currency)
message = replace_placeholder(message, "cost", elapsed_seconds * (tonumber(Config.Payphones.PricePerSecond) or 0))
else
- message = locale.RingingHelp
+ message = payphone_locale.RingingHelp
end
return replace_placeholder(message, "number", active_call_number or "")
end
@@ -577,7 +575,7 @@ end)
CreateThread(function()
while true do
if nearest_payphone and nearest_payphone.distance <= Config.Payphones.InteractionDistance and not IsNuiFocused() then
- Bridge.Framework.ShowHelpNotification(get_locale().Payphone.Interact, "E")
+ Bridge.Framework.ShowHelpNotification(locale.Payphone.Interact, "E")
if IsControlJustReleased(0, 38) then
open_payphone(nearest_payphone)
end
diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua
index d9b9496..0d2a338 100644
--- a/sky_phone/source/client/radio.lua
+++ b/sky_phone/source/client/radio.lua
@@ -295,7 +295,7 @@ RegisterNetEvent("sky_phone:radio:notification", function(data)
if not radio_settings.notifications or current_primary <= 0 then
return
end
- local locale = (Locales[Config.Bridge.Locale] or Locales.en).Nui.Apps.radio
+ local locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).Nui.Apps.radio
local template = data.joined and locale.memberJoined or locale.memberLeft
SendNUIMessage({
type = "notification:show",
diff --git a/sky_phone/source/server/easyshare.lua b/sky_phone/source/server/easyshare.lua
index 5617aec..ac7f7b6 100644
--- a/sky_phone/source/server/easyshare.lua
+++ b/sky_phone/source/server/easyshare.lua
@@ -263,7 +263,7 @@ local function canonical_crewlink_invite(device, invite_code)
if not group then
return nil
end
- local locale = (Locales[Config.Bridge.Locale] or Locales["en"]).Phone.Apps.crewlink
+ local locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).Nui.Apps.crewlink
local title = locale.shareInviteTitle:gsub("{group}", function() return group.name end)
local copy_text = locale.shareInviteBody
:gsub("{code}", function() return group.invite_code end)
diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua
index ed809f4..35b5762 100644
--- a/sky_phone/source/server/fliptok.lua
+++ b/sky_phone/source/server/fliptok.lua
@@ -914,7 +914,7 @@ Bridge.Callbacks.Register("sky_phone:fliptok:delete", function(source, data)
end)
RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
- local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).FlipTokCommand
+ local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).FlipTokCommand
local function command_message(template, values)
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
end
diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua
index 301b62f..9740d31 100644
--- a/sky_phone/source/server/picstagram.lua
+++ b/sky_phone/source/server/picstagram.lua
@@ -1435,7 +1435,7 @@ Bridge.Callbacks.Register("sky_phone:picstagram:admin-resolve-report", function(
end)
RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
- local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).PicstagramCommand
+ local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).PicstagramCommand
local function command_message(template, values)
return template:gsub("{(%w+)}", function(key)
return values[key] or ""