From 73d0b4c4753b61430fd22f2e529ba498b64c5569 Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 01/12] fix(ui): handle long localized navigation labels --- web/src/ui/NavBar.tsx | 75 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/web/src/ui/NavBar.tsx b/web/src/ui/NavBar.tsx index 0ed46beb9..b634221a0 100644 --- a/web/src/ui/NavBar.tsx +++ b/web/src/ui/NavBar.tsx @@ -1,5 +1,10 @@ -import { ChevronLeft } from 'lucide-react'; +import { useLayoutEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; +import { ChevronLeft } from 'lucide-react'; + +import { t } from '@/i18n'; + +type BackMode = 'full' | 'generic' | 'minimal'; export function NavBar({ backLabel, onBack, title, right, hairline = false, className = '' }: { backLabel?: string; @@ -9,24 +14,64 @@ export function NavBar({ backLabel, onBack, title, right, hairline = false, clas hairline?: boolean; className?: string; }) { + const genericLabel = t('common.back', 'Back'); + const slotRef = useRef(null); + const fullRef = useRef(null); + const genericRef = useRef(null); + const [mode, setMode] = useState('full'); + + // Like UIKit: a back title that does not fit falls back to the generic "Back", + // then to the chevron alone, instead of being truncated or overlapping the title. + useLayoutEffect(() => { + const slot = slotRef.current; + if (!slot || !onBack || !backLabel) return; + const pick = () => { + // Sub-pixel widths: rounded offsetWidth lets a label that is a fraction too wide truncate. + const room = slot.getBoundingClientRect().width - 24; // chevron + gap + const full = fullRef.current?.getBoundingClientRect().width ?? 0; + const generic = genericRef.current?.getBoundingClientRect().width ?? 0; + setMode(full <= room ? 'full' : generic <= room ? 'generic' : 'minimal'); + }; + pick(); + const ro = new ResizeObserver(pick); + ro.observe(slot); + return () => ro.disconnect(); + }, [onBack, backLabel, genericLabel]); + + const shownLabel = !backLabel ? undefined : mode === 'full' ? backLabel : mode === 'generic' ? genericLabel : undefined; + return ( -
- {onBack && ( - - )} +
+ {/* Equal-basis side slots keep the title centred; the back slot yields first. */} +
+ {onBack && ( + + )} + {onBack && backLabel && ( + + {backLabel} + {genericLabel} + + )} +
{title && ( -
- {title} +
+ {title}
)} - {right &&
{right}
} + {/* Bar buttons are never clipped; the title gives way instead. */} +
+ {right} +
{hairline &&
}
); From 4cb12ba2222ca173d25e57c923ba46e61002b72d Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 02/12] fix(settings): preserve localized and custom app names --- .../apps/settings/appearance/AppNamesPage.tsx | 16 ++++++++++------ .../settings/notifications/NotificationsPage.tsx | 9 ++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/web/src/apps/settings/appearance/AppNamesPage.tsx b/web/src/apps/settings/appearance/AppNamesPage.tsx index 43f591ea7..f75f8a8a4 100644 --- a/web/src/apps/settings/appearance/AppNamesPage.tsx +++ b/web/src/apps/settings/appearance/AppNamesPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { t } from '@/i18n'; +import { appLabel, t } from '@/i18n'; import { useTheme, APP_LABEL_MAX } from '@/stores/themeStore'; import { useInstalledAppsStore } from '@/stores/installedAppsStore'; import { ListGroup, ListRow } from '@/ui/ListGroup'; @@ -14,7 +14,8 @@ export function AppNamesPage({ onBack }: { onBack: () => void }) { const target = renaming === null ? null : apps.find(a => a.id === renaming) ?? null; const renamedCount = Object.keys(appLabels).length; - const sorted = [...apps].sort((a, b) => a.label.localeCompare(b.label)); + const sorted = [...apps].sort((a, b) => appLabel(a).localeCompare(appLabel(b))); + const originalLabel = target ? t('apps.' + target.id, target.label) : ''; return ( void }) { {sorted.map((app, i) => ( setRenaming(app.id)} divider={i < sorted.length - 1} @@ -49,11 +50,14 @@ export function AppNamesPage({ onBack }: { onBack: () => void }) { { setAppLabel(target.id, value); setRenaming(null); }} + onConfirm={value => { + setAppLabel(target.id, value.trim() === originalLabel ? '' : value); + setRenaming(null); + }} onCancel={() => setRenaming(null)} /> )} diff --git a/web/src/apps/settings/notifications/NotificationsPage.tsx b/web/src/apps/settings/notifications/NotificationsPage.tsx index 57f87dc5e..6abe8bb4c 100644 --- a/web/src/apps/settings/notifications/NotificationsPage.tsx +++ b/web/src/apps/settings/notifications/NotificationsPage.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { ChevronRight, SearchX } from 'lucide-react'; -import { t } from '@/i18n'; +import { appLabel, getCatalogVersion, t } from '@/i18n'; import { useIosPush } from '@/hooks/useIosPush'; import { useSessionState } from '@/hooks/useSessionState'; import { AppIconSVG } from '@/shell/AppIconSVG'; @@ -11,6 +11,7 @@ import { NavBar } from '@/ui/NavBar'; import { SearchBar } from '@/ui/SearchBar'; import { useInstalledApps } from '@/stores/installedAppsStore'; import { useNotifPrefsStore } from '@/stores/notifPrefsStore'; +import { useTheme } from '@/stores/themeStore'; import { AppNotificationsPage, type AppEntry } from './AppNotificationsPage'; import { PushLayer } from '../SettingsSubPage'; @@ -18,15 +19,17 @@ export function NotificationsPage({ onBack }: { onBack: () => void }) { const { goBack, pageStyle } = useIosPush(onBack); const installed = useInstalledApps(); + const { appLabels } = useTheme('appLabels'); + const catalogVersion = getCatalogVersion(); const prefs = useNotifPrefsStore(s => s.prefs); const [openApp, setOpenApp] = useState(null); const [query, setQuery] = useSessionState('settings:notifSearch', ''); const all = useMemo( () => installed - .map(a => ({ id: a.id, label: a.label })) + .map(a => ({ id: a.id, get label() { return appLabel(a); } })) .sort((a, b) => a.label.localeCompare(b.label)), - [installed], + [installed, appLabels, catalogVersion], ); const q = query.trim().toLowerCase(); From 904e159b65a609d85a5074b9b1fc67332b720f64 Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 03/12] fix(i18n): format bank and voice dates in the selected locale --- web/src/apps/banking/bankingApi.ts | 6 +++--- web/src/apps/voicememos/voiceApi.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/apps/banking/bankingApi.ts b/web/src/apps/banking/bankingApi.ts index 32f66b7ac..af2621bb9 100644 --- a/web/src/apps/banking/bankingApi.ts +++ b/web/src/apps/banking/bankingApi.ts @@ -1,5 +1,5 @@ import { fetchNui, isFiveM } from '@/core/nui'; -import { t } from '@/i18n'; +import { getLocaleTag, t } from '@/i18n'; import { ACCOUNTS, TRANSACTIONS } from './data'; import { apiData, type Envelope } from '@/core/api'; import { formatClockTime } from '@/lib/time'; @@ -243,7 +243,7 @@ export function groupTx(txs: BankTx[]): BankDay[] { let label: string; if (key === todayK) label = t('banking.today', 'Today'); else if (key === yestK) label = t('banking.yesterday', 'Yesterday'); - else label = new Date(key + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + else label = new Date(key + 'T00:00:00').toLocaleDateString(getLocaleTag(), { weekday: 'short', month: 'short', day: 'numeric' }); return { key, label, items: items.sort((a, b) => b.date.localeCompare(a.date)) }; }); } @@ -254,5 +254,5 @@ export function txTimeLabel(dateStr: string): string { if (dateStr.slice(0, 10) === todayK) { return formatClockTime(d, true); } - return d.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + return d.toLocaleDateString(getLocaleTag(), { day: 'numeric', month: 'short' }); } diff --git a/web/src/apps/voicememos/voiceApi.ts b/web/src/apps/voicememos/voiceApi.ts index 7eacaaab1..4de0a0c99 100644 --- a/web/src/apps/voicememos/voiceApi.ts +++ b/web/src/apps/voicememos/voiceApi.ts @@ -1,6 +1,6 @@ import { fetchNui, isFiveM } from '@/core/nui'; import { uploadDirect, uploadViaServer } from '@/shared/mediaUpload'; -import { t } from '@/i18n'; +import { getLocaleTag, t } from '@/i18n'; import { apiCall, apiData } from '@/core/api'; import { formatClockTime } from '@/lib/time'; @@ -85,5 +85,5 @@ export function fmtMemoDate(iso: string): string { const today = new Date(); if (dayK(d) === dayK(today)) return t('voicememos.today', 'Today {time}', { time }); if (dayK(d) === dayK(new Date(today.getTime() - 86_400_000))) return t('voicememos.yesterday', 'Yesterday {time}', { time }); - return `${d.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' })} ${time}`; + return `${d.toLocaleDateString(getLocaleTag(), { day: 'numeric', month: 'short', year: 'numeric' })} ${time}`; } From e44ed382d0bc99daba1a78d454620781dbf1e73a Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 04/12] fix(voicememos): localize upload errors and preserve legacy reasons --- client/apps/voicememos.lua | 4 ++-- server/voicememos/init.lua | 16 ++++++++-------- web/src/apps/voicememos/VoiceMemos.tsx | 13 +++++++++++-- web/src/core/types.ts | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/client/apps/voicememos.lua b/client/apps/voicememos.lua index 1faa62dbc..6d553293b 100644 --- a/client/apps/voicememos.lua +++ b/client/apps/voicememos.lua @@ -27,8 +27,8 @@ end) ---Server push: our upload was rejected (bad payload, too long, or the upstream upload ---failed); relays the reason to the app. ---@param message string human-readable failure reason from server/voicememos/init.lua -RegisterNetEvent('sd-phone:client:voice:uploadFailed', function(message) - SendNUIMessage({ action = 'sd-phone:voice:uploadFailed', data = { message = message } }) +RegisterNetEvent('sd-phone:client:voice:uploadFailed', function(message, messageKey) + SendNUIMessage({ action = 'sd-phone:voice:uploadFailed', data = { message = message, messageKey = messageKey } }) end) -- Direct upload. The event path above hands the whole recording to the server in one ordinary diff --git a/server/voicememos/init.lua b/server/voicememos/init.lua index 242309bed..5fb1cd1ac 100644 --- a/server/voicememos/init.lua +++ b/server/voicememos/init.lua @@ -115,9 +115,9 @@ util.onCleanup(function(src) pendingDirect[src] = nil end) ---@param why string|nil 'cooldown'|'busy'|'identity'|'budget'|'server' ---@return string message local function refusal(why) - if why == 'cooldown' then return 'Slow down a moment' end - if why == 'busy' then return 'Upload already in progress' end - return 'Upload limit reached, try again later' + if why == 'cooldown' then return 'Slow down a moment', 'voicememos.uploadSlowDown' end + if why == 'busy' then return 'Upload already in progress', 'voicememos.uploadBusy' end + return 'Upload limit reached, try again later', 'voicememos.uploadLimitReached' end ---Takes a recorded memo as a base64 audio data-URL, pushes it to the media provider and saves it. @@ -129,15 +129,15 @@ end ---@param prepaid boolean|nil true when an HTTP slot already holds the budget for it local function ingest(src, audio, name, duration, prepaid) if type(audio) ~= 'string' or not lib.string.startsWith(audio, 'data:audio/') then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Bad audio payload') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Bad audio payload', 'voicememos.badAudioPayload') return end if #audio > VM.MaxAudioBytes then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Recording is too long') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Recording is too long', 'voicememos.recordingTooLong') return end if uploading[src] then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload already in progress') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload already in progress', 'voicememos.uploadBusy') return end if not prepaid then @@ -159,14 +159,14 @@ local function ingest(src, audio, name, duration, prepaid) uploading[src] = nil if not url then print(('^1[sd-phone:voice]^0 upload failed: %s'):format(tostring(err))) - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, err or 'Upload failed') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload failed', 'voicememos.uploadFailed') return end local memo = actions.saveUploaded(src, url, name, duration) if memo then TriggerClientEvent('sd-phone:client:voice:added', src, memo) else - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Could not save memo') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Could not save memo', 'voicememos.couldNotSaveMemo') end end) end diff --git a/web/src/apps/voicememos/VoiceMemos.tsx b/web/src/apps/voicememos/VoiceMemos.tsx index ee048c13c..b95f95110 100644 --- a/web/src/apps/voicememos/VoiceMemos.tsx +++ b/web/src/apps/voicememos/VoiceMemos.tsx @@ -67,8 +67,17 @@ export function VoiceMemos({ onClose: _onClose }: { onClose: () => void }) { useNuiEvent('sd-phone:voice:added', useCallback((memo: VoiceMemo) => { setMemos(prev => (prev.some(m => m.id === memo.id) ? prev : [memo, ...prev])); }, [])); - useNuiEvent('sd-phone:voice:uploadFailed', useCallback((d: { message?: string }) => { - setError(d?.message ?? t('voicememos.uploadFailed', 'Upload failed')); + useNuiEvent('sd-phone:voice:uploadFailed', useCallback((d: { message?: string; messageKey?: string }) => { + const messages: Record string> = { + 'voicememos.uploadFailed': () => t('voicememos.uploadFailed', 'Upload failed'), + 'voicememos.badAudioPayload': () => t('voicememos.badAudioPayload', 'Bad audio payload'), + 'voicememos.recordingTooLong': () => t('voicememos.recordingTooLong', 'Recording is too long'), + 'voicememos.uploadBusy': () => t('voicememos.uploadBusy', 'Upload already in progress'), + 'voicememos.uploadSlowDown': () => t('voicememos.uploadSlowDown', 'Slow down a moment'), + 'voicememos.uploadLimitReached': () => t('voicememos.uploadLimitReached', 'Upload limit reached, try again later'), + 'voicememos.couldNotSaveMemo': () => t('voicememos.couldNotSaveMemo', 'Could not save memo'), + }; + setError(messages[d?.messageKey ?? '']?.() ?? d?.message ?? t('voicememos.uploadFailed', 'Upload failed')); }, [])); function onDelete(id: string) { diff --git a/web/src/core/types.ts b/web/src/core/types.ts index f37fbbc31..550e802a2 100644 --- a/web/src/core/types.ts +++ b/web/src/core/types.ts @@ -442,7 +442,7 @@ export type NuiMessage = | { action: 'sd-phone:documents:added'; data: { doc: DocFile } } | { action: 'sd-phone:documents:receive'; data: { doc: DocFile; fromName?: string } } | { action: 'sd-phone:documents:signRequest'; data: { requestId: string; fromName: string; doc: DocFile } } - | { action: 'sd-phone:voice:uploadFailed'; data: { message?: string } } + | { action: 'sd-phone:voice:uploadFailed'; data: { message?: string; messageKey?: string } } | { action: 'sd-phone:contacts:shared'; data: { id: string; name: string; phone: string; color: string; initials: string; avatar?: string } } | { action: 'sd-phone:contacts:removed'; data: { phone: string } } | { action: 'sd-phone:messages:incoming'; data: MessagesIncomingPush } From da211fb581eaaab02bb4b4b9289848313338b027 Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 05/12] fix(i18n): localize legacy count suffixes --- web/src/apps/music/Music.tsx | 12 ++++++------ web/src/core/api.ts | 9 +++++++-- web/src/i18n/index.ts | 6 ++++++ web/src/shell/spotlight/Spotlight.tsx | 4 ++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/web/src/apps/music/Music.tsx b/web/src/apps/music/Music.tsx index 34b431272..2fc5d422b 100644 --- a/web/src/apps/music/Music.tsx +++ b/web/src/apps/music/Music.tsx @@ -6,7 +6,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; -import { t } from '@/i18n'; +import { pluralSuffix, t } from '@/i18n'; import { useSessionState, clearSessionState } from '@/hooks/useSessionState'; import { useTheme } from '@/stores/themeStore'; import { useDidEnter } from '@/hooks/useDidEnter'; @@ -233,7 +233,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { key={f.id} art={} title={f.name} - subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: f.trackIds.length === 1 ? '' : 's' })} + subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: pluralSuffix(f.trackIds.length) })} onPress={() => push({ kind: 'playlist', id: f.id })} divider={i < folders.length - 1} action={f.trackIds.length > 0 ? setSharing({ kind: 'playlist', folder: f })} label={t('music.shareName', 'Share {name}', { name: f.name })} /> : undefined} @@ -285,7 +285,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { {artistHits.map((a, i) => ( push({ kind: 'artist', name: a.name })} divider={i < artistHits.length - 1} art={} - title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: a.tracks.length === 1 ? '' : 's' })} /> + title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: pluralSuffix(a.tracks.length) })} /> ))} {albumHits.length > 0 && {t('music.albums', 'Albums')}} @@ -320,7 +320,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { ) : artists.map((a, i) => ( push({ kind: 'artist', name: a.name })} divider={i < artists.length - 1} art={} - title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: a.tracks.length === 1 ? '' : 's' })} /> + title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: pluralSuffix(a.tracks.length) })} /> ))}
@@ -338,7 +338,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { } title={f.name} - subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: f.trackIds.length === 1 ? '' : 's' })} + subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: pluralSuffix(f.trackIds.length) })} onPress={() => push({ kind: 'playlist', id: f.id })} divider={i < folders.length - 1} action={f.trackIds.length > 0 ? setSharing({ kind: 'playlist', folder: f })} label={t('music.shareName', 'Share {name}', { name: f.name })} /> : undefined} @@ -448,7 +448,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { ) : (

{folder.name}

)} -

{t('music.songCount', '{count} song{plural}', { count: folderTracks.length, plural: folderTracks.length === 1 ? '' : 's' })}

+

{t('music.songCount', '{count} song{plural}', { count: folderTracks.length, plural: pluralSuffix(folderTracks.length) })}

diff --git a/web/src/core/api.ts b/web/src/core/api.ts index c49737d1d..ce88e2c06 100644 --- a/web/src/core/api.ts +++ b/web/src/core/api.ts @@ -1,5 +1,5 @@ import { fetchNui } from './nui'; -import { t } from '@/i18n'; +import { pluralSuffix, t } from '@/i18n'; export interface Envelope { success: boolean; @@ -16,7 +16,12 @@ export function failText( res: Pick, fallback: T, ): string | T { - if (res.messageKey && res.message !== undefined) return t(res.messageKey, res.message, res.messageVars); + if (res.messageKey && res.message !== undefined) { + const vars = res.messageKey === 'mail.nameMustLeastCharacters' && res.messageVars + ? { ...res.messageVars, s: pluralSuffix(Number(res.messageVars.n)) } + : res.messageVars; + return t(res.messageKey, res.message, vars); + } return res.message ?? fallback; } diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 74e8b9f05..b50a2e23c 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -174,6 +174,12 @@ export function t(key: string, fallback: string, vars?: Record Record = () => ({}); export function setAppLabelSource(source: () => Record): void { diff --git a/web/src/shell/spotlight/Spotlight.tsx b/web/src/shell/spotlight/Spotlight.tsx index 5821879cb..89df2a806 100644 --- a/web/src/shell/spotlight/Spotlight.tsx +++ b/web/src/shell/spotlight/Spotlight.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { device } from '@device'; -import { t, appLabel } from '@/i18n'; +import { t, appLabel, pluralSuffix } from '@/i18n'; import type { AppDef } from '@/core/types'; import { SearchBar } from '@/ui/SearchBar'; import { filterSettingsGroups, getSettingsGroups, pageForRow } from '@/apps/settings/data'; @@ -116,7 +116,7 @@ export function Spotlight({ apps, installableApps, open, pull, onLaunchApp, onCl const musicHits = useMemo(() => { if (!trimmed || !apps.some(a => a.id === 'music')) return []; const playlists = rankBy(musicFolders, trimmed, f => [f.name], 3) - .map(f => ({ id: f.id, title: f.name, subtitle: t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: f.trackIds.length === 1 ? '' : 's' }), extra: 'playlist' })); + .map(f => ({ id: f.id, title: f.name, subtitle: t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: pluralSuffix(f.trackIds.length) }), extra: 'playlist' })); const songs = rankBy(musicTracks, trimmed, tr => [tr.title, tr.artist], 3) .map(tr => ({ id: tr.id, title: tr.title, subtitle: tr.artist, extra: 'song' })); return [...playlists, ...songs]; From e3350a91c2385fbffad879367950c50566d8e201 Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 06/12] fix(i18n): use localized count suffixes in Ryde and news --- web/src/apps/ryde/driver/Driver.tsx | 8 ++++---- web/src/apps/weazelnews/ManageDashboard.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/apps/ryde/driver/Driver.tsx b/web/src/apps/ryde/driver/Driver.tsx index f35fa04c8..b3eadf99b 100644 --- a/web/src/apps/ryde/driver/Driver.tsx +++ b/web/src/apps/ryde/driver/Driver.tsx @@ -4,7 +4,7 @@ import { Car, ChevronRight, Download, Inbox, Navigation, Power, Star, TrendingUp import { useSessionState } from '@/hooks/useSessionState'; import { SegmentedControl } from '@/ui/SegmentedControl'; import { AlertDialog } from '@/ui/AlertDialog'; -import { t } from '@/i18n'; +import { pluralSuffix, t } from '@/i18n'; import { driverStats, useRyde } from '../store'; import { CAR_COLORS, money } from '../data'; import { BigButton } from '../ui'; @@ -140,7 +140,7 @@ function Dashboard() { {t('ryde.todaysEarnings', 'Today’s earnings')}

{money(s.earnToday)}

-

{t('ryde.tripsTodayCount', '{n} trip{plural} today', { n: s.tripsToday, plural: s.tripsToday === 1 ? '' : 's' })}

+

{t('ryde.tripsTodayCount', '{n} trip{plural} today', { n: s.tripsToday, plural: pluralSuffix(s.tripsToday) })}

From 42401033f3b23e436fc1e40bf6152ea01a9dcfa2 Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 11/12] fix(airshare): localize known notification attachment types --- server/share/core.lua | 12 +++++++++--- web/src/shell/Notifications.tsx | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/server/share/core.lua b/server/share/core.lua index 6d8327cf1..6ce38ec88 100644 --- a/server/share/core.lua +++ b/server/share/core.lua @@ -204,10 +204,16 @@ function core.respond(src, id, accept) -- just watch the prompt disappear: the recipient's client fires the respond callback and -- drops the card without reading the result, and the sender was only ever told on success. -- The recipient gets the handler's specific reason; the sender only needs to know it missed. - TriggerClientEvent('sd-phone:client:notify', src, { + local recipientNotice = { app = 'phone', title = 'AirShare', - body = reason or ('That %s could not be saved.'):format(what), - }) + bodyKey = 'share.couldNotSaveSharedItem', body = ('That %s could not be saved.'):format(what), + bodyVars = { kind = what }, + } + -- Handler-provided reasons are custom content, not the generic kind template. + if reason then + recipientNotice.body, recipientNotice.bodyKey, recipientNotice.bodyVars = reason, nil, nil + end + TriggerClientEvent('sd-phone:client:notify', src, recipientNotice) TriggerClientEvent('sd-phone:client:notify', req.fromSrc, { app = 'phone', title = 'AirShare', bodyKey = 'share.couldNotSaveYourShare', body = ('%s could not save your %s.'):format(who, what), diff --git a/web/src/shell/Notifications.tsx b/web/src/shell/Notifications.tsx index 8655896c1..eaa6dbe22 100644 --- a/web/src/shell/Notifications.tsx +++ b/web/src/shell/Notifications.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Bell } from 'lucide-react'; import { AppIconSVG } from './AppIconSVG'; import { t } from '@/i18n'; +import { localizeShareVars } from '@/i18n/dynamicLabels'; import { useStreamerHidden } from '@/stores/themeStore'; @@ -41,7 +42,7 @@ export interface NotifText { export function resolveNotifText(data: NotifText): { title: string; body?: string } { const title = data.titleKey ? t(data.titleKey, data.title, data.titleVars) : fill(data.title, data.titleVars); if (data.body === undefined) return { title }; - const body = data.bodyKey ? t(data.bodyKey, data.body, data.bodyVars) : fill(data.body, data.bodyVars); + const body = data.bodyKey ? t(data.bodyKey, data.body, localizeShareVars(data.bodyKey, data.bodyVars)) : fill(data.body, data.bodyVars); return { title, body }; } From 64a05e21a43831945ba1159fe487001b4c430f8d Mon Sep 17 00:00:00 2001 From: ziks Date: Tue, 10 Nov 2026 19:16:47 +0000 Subject: [PATCH 12/12] fix(i18n): add English and Russian keys for locale-aware UI --- locales/en.json | 31 ++++++++++++++++++ locales/ru.json | 85 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 89 insertions(+), 27 deletions(-) diff --git a/locales/en.json b/locales/en.json index 3f6e12b61..764433739 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1188,6 +1188,7 @@ "passwordsNoMatch": "Passwords do not match.", "pleaseCheckField": "Please check this field", "pleaseWait": "Please wait…", + "pluralSuffix": "s", "poster": "poster", "previous": "Previous", "recoverAccount": "Forgot your password? Tap here to recover your account", @@ -2145,6 +2146,7 @@ "noKeyHolders": "No one else has a key.", "owned": "Owned", "properties": "properties", + "propertiesFew": "properties", "property": "property", "remove": "Remove", "removeKeyHolder": "Remove Key Holder?", @@ -4085,6 +4087,8 @@ "appTitle": "Notes", "attachPhoto": "Attach photo", "attachmentAlt": "Attachment {n}", + "attachmentDrawing": "drawing", + "attachmentPhoto": "photo", "badNoteId": "Bad note id", "cancel": "Cancel", "clearCanvas": "Clear canvas", @@ -4539,6 +4543,7 @@ "storyRoomLeft": "Your story only has room for {n} more today", "storySlide": "Slide {n}", "storyTextEmpty": "Write something first", + "submitComment": "Post", "tagPeople": "Tag people", "tagSearchHint": "Search for anyone on Photogram to tag them.", "tagline": "Sign up to see photos from your friends.", @@ -6194,6 +6199,14 @@ "pasteYoutubeNotification": "Paste any YouTube link to use it as a notification tone.", "pasteYoutubeRingtone": "Paste any YouTube link to use it as a ringtone.", "phone": "Phone", + "phoneColorBlack": "Black", + "phoneColorBlue": "Blue", + "phoneColorGreen": "Green", + "phoneColorOrange": "Orange", + "phoneColorPink": "Pink", + "phoneColorPurple": "Purple", + "phoneColorRed": "Red", + "phoneColorYellow": "Yellow", "phonePosition": "Phone Position", "phonePositionHint": "Pick where the phone snaps to inside the game window. The phone scales toward this anchor so it stays pinned when you change the size above.", "phoneScale": "Phone Scale", @@ -6484,9 +6497,21 @@ }, "share": { "acceptedYourShare": "{name} accepted your {kind}.", + "couldNotSaveSharedItem": "That {kind} could not be saved.", "couldNotSaveYourShare": "{name} could not save your {kind}.", "declinedYourShare": "{name} declined your {kind}.", "invalidRecipient": "Invalid recipient", + "kindContact": "contact", + "kindDocument": "document", + "kindIdCard": "ID card", + "kindNote": "note", + "kindPhoto": "photo", + "kindPhotogramProfile": "Photogram profile", + "kindPin": "map pin", + "kindPlaylist": "playlist", + "kindSignatureRequest": "signature request", + "kindSong": "song", + "kindVoice": "voice memo", "recipientHasTooManyPending": "Recipient has too many pending shares", "recipientNoLongerNearby": "Recipient is no longer nearby", "requestExpired": "Request expired", @@ -7134,10 +7159,12 @@ "voicemailTitle": "Voicemail" }, "voicememos": { + "badAudioPayload": "Bad audio payload", "badMemoId": "Bad memo id", "cancel": "Cancel", "copied": "Copied!", "copyLink": "Copy Link", + "couldNotSaveMemo": "Could not save memo", "couldNotSendRequest": "Could not send request", "delete": "Delete", "deleteMessage": "“{name}” will be permanently deleted.", @@ -7156,6 +7183,7 @@ "pause": "Pause", "play": "Play", "record": "Record", + "recordingTooLong": "Recording is too long", "recordingUnsupported": "Recording is not supported here.", "rename": "Rename", "renameMessage": "Enter a new name for this recording.", @@ -7169,7 +7197,10 @@ "title": "Voice Memos", "today": "Today {time}", "toneLimit": "You can save up to {n} custom tones. Remove one in Settings first.", + "uploadBusy": "Upload already in progress", "uploadFailed": "Upload failed", + "uploadLimitReached": "Upload limit reached, try again later", + "uploadSlowDown": "Slow down a moment", "useAsNotification": "Use as Notification Sound", "useAsRingtone": "Use as Ringtone", "yesterday": "Yesterday {time}" diff --git a/locales/ru.json b/locales/ru.json index fbb36ade5..e41988285 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1226,8 +1226,9 @@ "wantsYouToFollowHandle": "{name} хочет, чтобы вы подписались на @{handle} в Photogram", "welcomeBack": "С возвращением", "wiFiUnavailable": "Wi-Fi недоступен", - "wouldLikeToShare": "{name} хочет поделиться {kind}", - "yes": "Да" + "wouldLikeToShare": "{name} хочет отправить вам объект: {kind}", + "yes": "Да", + "pluralSuffix": "" }, "compass": { "dirE": "В", @@ -2140,8 +2141,8 @@ "locked": "Заблокировано", "noKeyHolders": "Больше ни у кого нет ключа.", "owned": "В собственности", - "properties": "дома", - "property": "дом", + "properties": "объектов недвижимости", + "property": "объект недвижимости", "remove": "Удалить", "removeKeyHolder": "Удалить владельца ключа?", "removeKeyMessage": "{name} больше не будет иметь ключа к {address}.", @@ -2150,9 +2151,10 @@ "sourceId": "Идентификатор источника", "title": "Дома", "type": "Тип", - "unlock": "Разблокировать", - "unlocked": "Разблокировано", - "waypoint": "Путевая точка" + "unlock": "Отпереть", + "unlocked": "Не заперто", + "waypoint": "Путевая точка", + "propertiesFew": "объекта недвижимости" }, "id": { "copied": "Скопировано", @@ -2306,7 +2308,7 @@ "moveToSpam": "Переместить в Спам", "moveToSpamConfirm": "Переместить это письмо в папку «Спам»?", "nameMustCharactersFewer": "Имя не длиннее {n} символов", - "nameMustLeastCharacters": "Имя должно быть не короче {n} символ{s}", + "nameMustLeastCharacters": "Минимальная длина имени: {n} симв.{s}", "newMessage": "Новое сообщение", "next": "Далее", "noDocuments": "Никаких документов для прикрепления. Сначала создайте его в Файлах.", @@ -4044,7 +4046,7 @@ "playPause": "Воспроизведение/Пауза", "playlist": "Плейлист", "playlistName": "Название плейлиста", - "playlistSongCount": "Плейлист · {count} песня{plural}", + "playlistSongCount": "Плейлист · Треков: {count}{plural}", "playlists": "Плейлисты", "previous": "Предыдущий", "recentlyAdded": "Недавно добавлено", @@ -4063,8 +4065,8 @@ "shareTitle": "Поделиться {title}", "sharedArtist": "Отправлено", "sharedPlaylist": "Общий плейлист", - "shuffle": "Перетасовать", - "songCount": "{count} песня{plural}", + "shuffle": "Перемешать", + "songCount": "Треков: {count}{plural}", "songs": "Песни", "sourceBlocked": "Эта ссылка не из разрешенного источника.", "stop": "Стоп", @@ -4131,7 +4133,9 @@ "strokeWidthAria": "Ширина штриха {w}", "undoStroke": "Отменить последний штрих", "viewDrawing": "Посмотреть рисунок {n}", - "viewPhoto": "Посмотреть фото {n}" + "viewPhoto": "Посмотреть фото {n}", + "attachmentDrawing": "рисунок", + "attachmentPhoto": "фото" }, "pages": { "alreadyPublished": "Этот пост уже опубликован", @@ -4489,7 +4493,7 @@ "password": "Пароль", "phone": "Телефон", "photoLabel": "📷 Фото", - "post": "Сообщение", + "post": "Публикация", "postLabel": "📷 Публикация", "postNotFound": "Пост не найден", "posts": "Сообщения", @@ -4551,7 +4555,8 @@ "you": "ты", "youAreTheHost": "Вы ведущий(-ая)", "youColon": "Вы: {body}", - "yourStory": "Ваша история" + "yourStory": "Ваша история", + "submitComment": "Отправить" }, "photos": { "add": "Добавить", @@ -5191,8 +5196,8 @@ "platePlaceholder": "например 12 АВС 34", "rateYourDriver": "Оцените своего водителя", "rating": "Рейтинг", - "ratingRider": "{rating} · Райдер", - "ratingsCount": "{n} рейтинг{plural}", + "ratingRider": "{rating} · Пассажир", + "ratingsCount": "Оценок: {n}{plural}", "requestARide": "Запросить поездку?", "requestRide": "Запросить поездку", "requestRideMessage": "Ближайшему водителю будет предложено отвезти вас до {name}, а затем предложить приемлемый для вас тариф.", @@ -5241,9 +5246,9 @@ "totalTrips": "Всего поездок", "trips": "Поездки", "tripsCount": "{n} поездок", - "tripsEarned": "{n} поездка{plural} · {amount} заработано", + "tripsEarned": "Поездок: {n}{plural} · Заработано: {amount}", "tripsTaken": "Совершено поездок", - "tripsTodayCount": "{n} поездка{plural} сегодня", + "tripsTodayCount": "Поездок сегодня: {n}{plural}", "unregister": "Отменить регистрацию", "unregisterMessage": "{vehicle}{plate} будет удален. Ваш рейтинг и количество поездок сохраняются, и вы сразу можете зарегистрировать новый автомобиль.", "unregisterTitle": "Отменить регистрацию автомобиля?", @@ -6434,7 +6439,15 @@ "youtubeLink": "Ссылка YouTube", "fromVoiceMemos": "Из Голосовых заметок", "toneLimit": "Можно сохранить до {n} собственных мелодий. Сначала удалите одну выше.", - "voiceMemoToneHint": "Выбор записи переносит её в ваши мелодии выше." + "voiceMemoToneHint": "Выбор записи переносит её в ваши мелодии выше.", + "phoneColorBlack": "Чёрный", + "phoneColorBlue": "Синий", + "phoneColorGreen": "Зелёный", + "phoneColorOrange": "Оранжевый", + "phoneColorPink": "Розовый", + "phoneColorPurple": "Фиолетовый", + "phoneColorRed": "Красный", + "phoneColorYellow": "Жёлтый" }, "setup": { "allSet": "Все готово", @@ -6485,9 +6498,21 @@ "recipientNoLongerNearby": "Получателя больше нет рядом", "requestExpired": "Срок запроса истёк", "slowDownMoment": "Не так быстро", - "tooLargeShare": "Это слишком большое, чтобы поделиться", - "tooManyPendingShares": "Слишком много ожидающих отправлений", - "unknownShareType": "Неизвестный тип отправления" + "tooLargeShare": "Объект слишком большой для передачи", + "tooManyPendingShares": "Слишком много запросов на передачу без ответа", + "unknownShareType": "Неизвестный тип передаваемого объекта", + "couldNotSaveSharedItem": "Не удалось сохранить: {kind}.", + "kindContact": "контакт", + "kindDocument": "документ", + "kindIdCard": "удостоверение", + "kindNote": "заметка", + "kindPhoto": "фото", + "kindPhotogramProfile": "профиль Photogram", + "kindPin": "метка на карте", + "kindPlaylist": "плейлист", + "kindSignatureRequest": "запрос подписи", + "kindSong": "песня", + "kindVoice": "голосовая заметка" }, "shell": { "airplaneMode": "Режим полета", @@ -7166,7 +7191,13 @@ "ringtoneSet": "Рингтон установлен", "toneLimit": "Можно сохранить до {n} собственных мелодий. Сначала удалите одну в Настройках.", "useAsNotification": "Использовать как звук уведомления", - "useAsRingtone": "Использовать как рингтон" + "useAsRingtone": "Использовать как рингтон", + "badAudioPayload": "Некорректные аудиоданные", + "couldNotSaveMemo": "Не удалось сохранить запись", + "recordingTooLong": "Запись слишком длинная", + "uploadBusy": "Запись уже загружается", + "uploadLimitReached": "Достигнут лимит загрузок. Попробуйте позже.", + "uploadSlowDown": "Подождите немного перед повторной загрузкой" }, "weather": { "closeWeather": "Закрыть Погоду", @@ -7280,9 +7311,9 @@ "standfirstPlaceholder": "Однострочное резюме, отображаемое под заголовком (необязательно).", "stories": "Истории", "storyHeadlinePlaceholder": "Заголовок материала", - "tapGearToPublish": "Нажмите на шестеренку, чтобы опубликовать первую историю.", - "tapNewPost": "Нажмите «Новая запись», чтобы опубликовать первую историю.", - "tickerSummary": "{count} заголовок{plural} · {first}", + "tapGearToPublish": "Нажмите на шестерёнку, чтобы опубликовать первый материал.", + "tapNewPost": "Нажмите «Новый пост», чтобы опубликовать первый материал.", + "tickerSummary": "Заголовков: {count}{plural} · {first}", "timeAgo": "{time} назад", "url": "URL-адрес", "viewsMillions": "{n}M", @@ -7385,4 +7416,4 @@ "noNumberToShare": "У вашего телефона нет номера, которым можно поделиться.", "theyHaveNoPhone": "У этого человека нет телефона." } -} \ No newline at end of file +}