diff --git a/package-lock.json b/package-lock.json index 726db1d..af9d51a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,6 @@ "electron": "^21.3.1", "electron-builder": "^23.6.0", "i18next": "^22.4.6", - "i18next-browser-languagedetector": "^7.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^12.1.1", @@ -2785,15 +2784,6 @@ "@babel/runtime": "^7.20.6" } }, - "node_modules/i18next-browser-languagedetector": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.0.1.tgz", - "integrity": "sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.19.4" - } - }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -6892,15 +6882,6 @@ "@babel/runtime": "^7.20.6" } }, - "i18next-browser-languagedetector": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.0.1.tgz", - "integrity": "sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g==", - "dev": true, - "requires": { - "@babel/runtime": "^7.19.4" - } - }, "iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", diff --git a/package.json b/package.json index 7f647cc..24edd61 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "electron": "^21.3.1", "electron-builder": "^23.6.0", "i18next": "^22.4.6", - "i18next-browser-languagedetector": "^7.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^12.1.1", diff --git a/src/app/browser/add-account-manual/index.tsx b/src/app/browser/add-account-manual/index.tsx index 94cbaec..3f9822b 100644 --- a/src/app/browser/add-account-manual/index.tsx +++ b/src/app/browser/add-account-manual/index.tsx @@ -53,7 +53,7 @@ function AddAccountManualPrompt(props: AddAccountManualPromptProps & { {t('response_heading')} - {t('response_help_1', {url: 'npf{props.client_id}://auth'})} + {t('response_help_1', {url: `npf${props.client_id}://auth`})} {t('response_help_2')} 'friendcode' in props ? props.friendcode : { + id: props.id, + regenerable: false, + regenerableAt: 0, + }, ['friendcode' in props ? props.friendcode : null, 'id' in props ? props.id : null]); + + const onFriendCodeContextMenu = useCallback(() => { + ipc.showFriendCodeMenu(friendcode); + }, [ipc, friendcode]); + + return SW-{friendcode.id}; +} + +const styles = StyleSheet.create({ + friendCodeValue: { + // @ts-expect-error + userSelect: 'all', + }, +}); diff --git a/src/app/browser/components/icons/add-outline.tsx b/src/app/browser/components/icons/add-outline.tsx index 4f57c9a..27fe3e6 100644 --- a/src/app/browser/components/icons/add-outline.tsx +++ b/src/app/browser/components/icons/add-outline.tsx @@ -2,8 +2,13 @@ import React from 'react'; import { Platform, Text } from 'react-native'; import { svg_styles } from './util.js'; -const IconWeb = React.memo(() => - Add +const IconWeb = React.memo((props: { + title?: string; +}) => + + {props.title ? {props.title} : null} + + ); export default Platform.OS === 'web' ? IconWeb : React.memo(() => null); diff --git a/src/app/browser/components/icons/warning.tsx b/src/app/browser/components/icons/warning.tsx index d1a2e15..91bba07 100644 --- a/src/app/browser/components/icons/warning.tsx +++ b/src/app/browser/components/icons/warning.tsx @@ -2,8 +2,13 @@ import React from 'react'; import { Platform, Text } from 'react-native'; import { svg_styles } from './util.js'; -const IconWeb = React.memo(() => - ionicons-v5-r +const IconWeb = React.memo((props: { + title?: string; +}) => + + {props.title ? {props.title} : null} + + ); export default Platform.OS === 'web' ? IconWeb : React.memo(() => null); diff --git a/src/app/browser/components/index.ts b/src/app/browser/components/index.ts index 5ac39b4..05c5e36 100644 --- a/src/app/browser/components/index.ts +++ b/src/app/browser/components/index.ts @@ -1,3 +1,4 @@ export { default as Button } from './button.js'; export { default as NintendoSwitchUser } from './nintendo-switch-user.js'; export { NintendoSwitchUsers } from './nintendo-switch-user.js'; +export { default as FriendCode } from './friend-code.js'; diff --git a/src/app/browser/friend/index.tsx b/src/app/browser/friend/index.tsx index 6b9f928..00b8262 100644 --- a/src/app/browser/friend/index.tsx +++ b/src/app/browser/friend/index.tsx @@ -48,7 +48,7 @@ export default function FriendWindow(props: FriendProps) { if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) { return @@ -62,7 +62,7 @@ export default function FriendWindow(props: FriendProps) { const can_see_user_presence = user.nsoAccount.user.permissions.presence === PresencePermissions.FRIENDS || (user.nsoAccount.user.permissions.presence === PresencePermissions.FAVORITE_FRIENDS && friend.isFavoriteFriend); - return + return {props.game.name} {props.game.sysDescription ? {props.game.sysDescription} : null} - {t('game_played_for', {duration: hrduration(props.game.totalPlayTime)})} + {props.game.totalPlayTime >= 60 ? + props.game.totalPlayTime % 60 ? + t('game_played_for_hm', {hours: Math.floor(props.game.totalPlayTime / 60), + minutes: props.game.totalPlayTime % 60}) : + t('game_played_for_h', {hours: props.game.totalPlayTime / 60}) : + t('game_played_for_m', {minutes: props.game.totalPlayTime})} {first_played ? t('game_first_played', { diff --git a/src/app/browser/main/friends.tsx b/src/app/browser/main/friends.tsx index 5d5b258..9d84d20 100644 --- a/src/app/browser/main/friends.tsx +++ b/src/app/browser/main/friends.tsx @@ -1,12 +1,13 @@ import React, { useCallback } from 'react'; import { Image, ImageStyle, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import ipc from '../ipc.js'; import { useAccentColour, useColourScheme, User, useTimeSince } from '../util.js'; import { Friend, Presence, PresenceState } from '../../../api/coral-types.js'; import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js'; import Section, { HEADER_SIZE } from './section.js'; import AddOutline from '../components/icons/add-outline.js'; +import { FriendCode } from '../components/index.js'; export default function Friends(props: { user: User; @@ -23,19 +24,9 @@ export default function Friends(props: { }, [props.user.user.id]); const header_buttons = - + ; - const onFriendCodeContextMenu = useCallback(() => { - ipc.showFriendCodeMenu(props.user.nso!.nsoAccount.user.links.friendCode); - }, [ipc, props.user.nso?.nsoAccount.user.links.friendCode]); - - const fc = SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}; - return
{props.friends.length ? @@ -43,11 +34,19 @@ export default function Friends(props: { : {t('no_friends')} - {t('friend_code')}: {fc} + + + + + } {props.friends.length ? - {t('friend_code')}: {fc} + + + + + : null}
; } @@ -99,7 +98,7 @@ function FriendPresence(props: { const { t, i18n } = useTranslation('main_window', { keyPrefix: 'friends_section' }); const logout = props.presence.logoutAt ? new Date(props.presence.logoutAt * 1000) : null; - const since_logout = useTimeSince(logout ?? new Date(0), true); + const since_logout = useTimeSince(logout ?? new Date(0), true, i18n.getFixedT(null, 'time_since')); if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) { return {t('presence_playing')}; @@ -124,10 +123,6 @@ const styles = StyleSheet.create({ fontSize: 12, opacity: 0.7, }, - friendCodeValue: { - // @ts-expect-error - userSelect: 'all', - }, content: { paddingBottom: 16, paddingLeft: ipc.platform === 'win32' ? 24 : 20, diff --git a/src/app/browser/main/index.tsx b/src/app/browser/main/index.tsx index 7ddd8c4..097ba70 100644 --- a/src/app/browser/main/index.tsx +++ b/src/app/browser/main/index.tsx @@ -36,7 +36,7 @@ export default function App(props: AppProps) { titleUser={selectedUser} vibrancy={props.vibrancy} style={[styles.app, !props.vibrancy ? theme.appNoVibrancy : null]} - i18nNamespace="main_window" + i18nNamespace={['main_window', 'time_since']} > ) { const theme = useColourScheme() === 'light' ? light : dark; const accent_colour = useAccentColour(); + const { t, i18n } = useTranslation('main_window', { keyPrefix: 'main_section' }); const showErrorDetails = useCallback(() => { alert(props.error); @@ -24,7 +26,7 @@ export default function Section(props: React.PropsWithChildren<{ {props.loading ? : props.error ? - + : null} {props.headerButtons} diff --git a/src/app/browser/util.tsx b/src/app/browser/util.tsx index c5dee59..d5c70e5 100644 --- a/src/app/browser/util.tsx +++ b/src/app/browser/util.tsx @@ -1,9 +1,8 @@ import { EventEmitter } from 'node:events'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; -import { ColorSchemeName, I18nManager, LayoutChangeEvent, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native'; -import { i18n } from 'i18next'; +import { ColorSchemeName, LayoutChangeEvent, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native'; +import { i18n, TFunction } from 'i18next'; import { I18nextProvider, initReactI18next } from 'react-i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; import type { User as DiscordUser } from 'discord-rpc'; import { ErrorResponse } from '../../api/util.js'; import { DiscordPresence } from '../../discord/types.js'; @@ -49,10 +48,11 @@ export function Root(props: React.PropsWithChildren<{ // @ts-expect-error window.i18n = i18n; - await i18n - .use(LanguageDetector as unknown as typeof import('i18next-browser-languagedetector').default) - .use(initReactI18next) - .init(); + i18n.use(initReactI18next); + + await i18n.init({ + lng: ipc.getLanguage(), + }); await i18n.loadNamespaces('app'); if (props.i18nNamespace) await i18n.loadNamespaces(props.i18nNamespace); @@ -305,10 +305,10 @@ export function useActiveDiscordUser() { return user; } -export function useTimeSince(time: Date, short = false) { +export function useTimeSince(time: Date, short = false, t?: TFunction) { const [now, setNow] = useState(Date.now()); - const [since, update_in] = getTimeSince(time, now, short ? short_time_since_intervals : time_since_intervals); + const [since, update_in] = getTimeSince(time, now, short ? short_time_since_intervals : time_since_intervals, t); const update_at = Date.now() + update_in; useEffect(() => { @@ -323,24 +323,28 @@ interface TimeSinceInterval { interval: number; max: number; string: (count: number) => string; + key?: string; } const time_since_intervals: TimeSinceInterval[] = [ - {interval: 1000, max: 10, string: () => 'just now'}, - {interval: 1000, max: 60, string: c => c + ' second' + (c === 1 ? '' : 's') + ' ago'}, - {interval: 60 * 1000, max: 60, string: c => c + ' minute' + (c === 1 ? '' : 's') + ' ago'}, - {interval: 60 * 60 * 1000, max: 24, string: c => c + ' hour' + (c === 1 ? '' : 's') + ' ago'}, - {interval: 24 * 60 * 60 * 1000, max: Infinity, string: c => c + ' day' + (c === 1 ? '' : 's') + ' ago'}, + {interval: 1000, max: 10, string: () => 'just now', key: 'default.now'}, + {interval: 1000, max: 60, string: c => c + ' second' + (c === 1 ? '' : 's') + ' ago', key: 'default.seconds'}, + {interval: 60 * 1000, max: 60, string: c => c + ' minute' + (c === 1 ? '' : 's') + ' ago', key: 'default.minutes'}, + {interval: 60 * 60 * 1000, max: 24, string: c => c + ' hour' + (c === 1 ? '' : 's') + ' ago', key: 'default.hours'}, + {interval: 24 * 60 * 60 * 1000, max: Infinity, string: c => c + ' day' + (c === 1 ? '' : 's') + ' ago', key: 'default.days'}, ]; const short_time_since_intervals: TimeSinceInterval[] = [ - {interval: 1000, max: 10, string: () => 'Just now'}, - {interval: 1000, max: 60, string: c => c + ' sec' + (c === 1 ? '' : 's')}, - {interval: 60 * 1000, max: 60, string: c => c + ' min' + (c === 1 ? '' : 's')}, - {interval: 60 * 60 * 1000, max: 24, string: c => c + ' hr' + (c === 1 ? '' : 's')}, - {interval: 24 * 60 * 60 * 1000, max: Infinity, string: c => c + ' day' + (c === 1 ? '' : 's')}, + {interval: 1000, max: 10, string: () => 'Just now', key: 'default.now'}, + {interval: 1000, max: 60, string: c => c + ' sec' + (c === 1 ? '' : 's'), key: 'short.seconds'}, + {interval: 60 * 1000, max: 60, string: c => c + ' min' + (c === 1 ? '' : 's'), key: 'short.minutes'}, + {interval: 60 * 60 * 1000, max: 24, string: c => c + ' hr' + (c === 1 ? '' : 's'), key: 'short.hours'}, + {interval: 24 * 60 * 60 * 1000, max: Infinity, string: c => c + ' day' + (c === 1 ? '' : 's'), key: 'short.days'}, ]; -function getTimeSince(time: Date | number, now = Date.now(), intervals = time_since_intervals): [string, number] { +function getTimeSince( + time: Date | number, now = Date.now(), + intervals = time_since_intervals, t?: TFunction, +): [string, number] { if (time instanceof Date) time = time.getTime(); const elapsed = Math.max(0, now - time); @@ -349,7 +353,10 @@ function getTimeSince(time: Date | number, now = Date.now(), intervals = time_si for (const i of intervals) { if (elapsed < i.max * i.interval || last === i) { const count = Math.floor(elapsed / i.interval); - return [i.string.call(null, count), i.interval - (elapsed - (count * i.interval))]; + return [ + (t && i.key ? t(i.key, {count, defaultValue: ''}) : '') || i.string.call(null, count), + i.interval - (elapsed - (count * i.interval)), + ]; } } diff --git a/src/app/i18n/index.ts b/src/app/i18n/index.ts index cfff1a5..b7fde45 100644 --- a/src/app/i18n/index.ts +++ b/src/app/i18n/index.ts @@ -3,12 +3,35 @@ import { BackendModule, CallbackError, createInstance, ReadCallback } from 'i18n const debug = createDebug('app:i18n'); +import './locale/en-gb.js'; + export const languages = { - 'en-GB': [() => import('./locale/en-gb.js'), 'English'] as const, - 'de-DE': [() => import('./locale/de-de.js'), 'Deutsch'] as const, + 'en-GB': { + name: 'English', + app: () => import('./locale/en-gb.js'), + authors: [ + ['Samuel Elliott', 'https://gitlab.fancy.org.uk/samuel', 'https://github.com/samuelthomas2774'], + ], + }, }; -type Namespace = keyof typeof import('./locale/en-gb.js'); +const namespaces = { + app: 'app', + app_menu: 'app', + menu_app: 'app', + menus: 'app', + notifications: 'app', + handle_uri: 'app', + + main_window: 'app', + preferences_window: 'app', + friend_window: 'app', + addfriend_window: 'app', + discordsetup_window: 'app', + addaccountmanual_window: 'app', +} as const; + +type Namespace = keyof typeof namespaces; export default function createI18n() { const i18n = createInstance({ @@ -41,7 +64,7 @@ const LanguageBackend: BackendModule = { ) => { debug('Loading %s translations for %s', namespace, language); - importLocale(language).then(resources => { + importLocale(language, namespaces[namespace]).then(resources => { callback(null, resources[namespace as keyof typeof resources]); }, (error: CallbackError) => { callback(error, null); @@ -50,8 +73,11 @@ const LanguageBackend: BackendModule = { init: null as any, }; -async function importLocale(language: keyof typeof languages) { +async function importLocale( + language: keyof typeof languages, + chunk: typeof namespaces[keyof typeof namespaces] = 'app', +) { if (!(language in languages)) throw new Error('Unknown language ' + language); - return languages[language][0](); + return languages[language][chunk](); } diff --git a/src/app/i18n/locale/en-gb.ts b/src/app/i18n/locale/en-gb.ts index eddf568..869ad3c 100644 --- a/src/app/i18n/locale/en-gb.ts +++ b/src/app/i18n/locale/en-gb.ts @@ -5,6 +5,7 @@ export const app = { licence: LICENCE_NOTICE, credits: CREDITS_NOTICE, + translation_credits: '{{language}} translation by {{authors, list}}.', }; export const app_menu = { @@ -68,7 +69,15 @@ export const menus = { friend: { presence_online: 'Online', game_first_played: 'First played: {{date, datetime}}', - game_play_time: 'Play time: {{time, datetime}}', + + game_play_time_h: 'Play time: $t(friend.hours, {"count": {{hours}}})', + game_play_time_hm: 'Play time: $t(friend.hours, {"count": {{hours}}}), $t(friend.minutes, {"count": {{minutes}}})', + game_play_time_m: 'Play time: $t(friend.minutes, {"count": {{minutes}}})', + hours_one: '{{count}} hour', + hours_other: '{{count}} hours', + minutes_one: '{{count}} minute', + minutes_other: '{{count}} minutes', + presence_inactive: 'Offline (console online)', presence_offline: 'Offline', presence_updated: 'Updated: {{date, datetime}}', @@ -90,6 +99,32 @@ export const handle_uri = { cancel: 'Cancel', }; +export const time_since = { + default: { + now: 'just now', + seconds_one: '{{count}} second ago', + seconds_other: '{{count}} seconds ago', + minutes_one: '{{count}} minute ago', + minutes_other: '{{count}} minutes ago', + hours_one: '{{count}} hour ago', + hours_other: '{{count}} hours ago', + days_one: '{{count}} day ago', + days_other: '{{count}} days ago', + }, + + short: { + now: 'Just now', + seconds_one: '{{count}} sec', + seconds_other: '{{count}} secs', + minutes_one: '{{count}} min', + minutes_other: '{{count}} mins', + hours_one: '{{count}} hr', + hours_other: '{{count}} hrs', + days_one: '{{count}} day', + days_other: '{{count}} days', + }, +}; + export const main_window = { sidebar: { discord_active: 'Discord Rich Presence active', @@ -127,6 +162,8 @@ export const main_window = { desc_2: 'Login to the Nintendo Switch Online app to view details here, or use the nxapi command to access Parental Controls data.', login: 'Login', }, + + section_error: 'Error updating data', }, discord_section: { @@ -145,9 +182,10 @@ export const main_window = { friends_section: { title: 'Friends', + add: 'Add', no_friends: 'Add friends using a Nintendo Switch console.', - friend_code: 'Your friend code', + friend_code: 'Your friend code: <0>', presence_playing: 'Playing', presence_offline: 'Offline', @@ -240,7 +278,14 @@ export const friend_window = { presence_offline: 'Offline', presence_last_seen: 'Last seen {{since_logout}}', - game_played_for: 'Played for {{duration}}', + game_played_for_h: 'Played for $t(hours, {"count": {{hours}}})', + game_played_for_hm: 'Played for $t(hours, {"count": {{hours}}}), $t(minutes, {"count": {{minutes}}})', + game_played_for_m: 'Played for $t(minutes, {"count": {{minutes}}})', + hours_one: '{{count}} hour', + hours_other: '{{count}} hours', + minutes_one: '{{count}} minute', + minutes_other: '{{count}} minutes', + game_first_played: 'First played {{date, datetime}}', game_first_played_now: 'First played now', game_title_id: 'Title ID', diff --git a/src/app/main/index.ts b/src/app/main/index.ts index b0c2ec5..612a482 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -21,7 +21,7 @@ import { askUserForUri } from './util.js'; import { setAppInstance, updateMenuLanguage } from './app-menu.js'; import { handleAuthUri } from './na-auth.js'; import { CREDITS_NOTICE, GITLAB_URL, LICENCE_NOTICE } from '../../common/constants.js'; -import createI18n from '../i18n/index.js'; +import createI18n, { languages } from '../i18n/index.js'; const debug = createDebug('app:main'); @@ -131,11 +131,13 @@ export class App { } static detectSystemLanguage() { - const languages = app.getPreferredSystemLanguages().map(l => l.toLowerCase()); + const preferred = app.getPreferredSystemLanguages().map(l => l.toLowerCase()); const supported = Object.keys(languages).map(l => l.toLowerCase()); - for (const language of languages) { - if (supported.some(l => language.startsWith(l) || l.startsWith(language))) return language; + debug('prefers %O, supports %O', preferred, supported); + + for (const language of preferred) { + if (supported.some(l => language.startsWith(l) || l.startsWith(language))) return language; } return null; @@ -143,6 +145,8 @@ export class App { } function setAboutPanelOptions(i18n?: i18n) { + const language = i18n ? languages[i18n.resolvedLanguage as keyof typeof languages] : undefined; + app.setAboutPanelOptions({ applicationName: 'nxapi-app', applicationVersion: process.platform === 'darwin' ? version : version + @@ -150,7 +154,10 @@ function setAboutPanelOptions(i18n?: i18n) { version: git?.revision.substr(0, 8) ?? '?', authors: ['Samuel Elliott'], website: GITLAB_URL, - credits: i18n?.t('app:credits') ?? CREDITS_NOTICE, + credits: (i18n?.t('app:credits') ?? CREDITS_NOTICE) + + (language?.authors.length ? '\n\n' + i18n!.t('app:translation_credits', { + language: language.name, authors: language.authors.map(a => a[0]), + }) : ''), copyright: i18n?.t('app:licence') ?? LICENCE_NOTICE, }); } diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 1135e1c..bb9e594 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -28,6 +28,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { const t = appinstance.i18n.getFixedT(null, 'menus'); ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender)); + ipcMain.on('nxapi:app:language', e => e.returnValue = appinstance.i18n.resolvedLanguage ?? appinstance.i18n.language); let accent_colour = systemPreferences.getAccentColor?.() || undefined; @@ -315,9 +316,12 @@ function buildFriendMenu(app: App, user: NintendoAccountUser, nso: CurrentUser, date: new Date(friend.presence.game.firstPlayedAt * 1000), formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } }, })!, enabled: false}), - new MenuItem({label: t('game_play_time', { - time: hrduration(friend.presence.game.totalPlayTime), - })!, enabled: false}), + new MenuItem({label: friend.presence.game.totalPlayTime >= 60 ? + friend.presence.game.totalPlayTime % 60 ? + t('game_play_time_hm', {hours: Math.floor(friend.presence.game.totalPlayTime / 60), + minutes: friend.presence.game.totalPlayTime % 60})! : + t('game_play_time_h', {hours: friend.presence.game.totalPlayTime / 60})! : + t('game_play_time_m', {minutes: friend.presence.game.totalPlayTime})!, enabled: false}), ] : []), new MenuItem({label: t('presence_updated', { date: new Date(friend.presence.updatedAt * 1000), diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 4fd7ec6..4d5a8c0 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -14,6 +14,7 @@ import { createWindow } from './windows.js'; import { WindowType } from '../common/types.js'; import CoralApi from '../../api/coral.js'; import { CachedWebServicesList } from '../../common/users.js'; +import { languages } from '../i18n/index.js'; const debug = createDebug('app:main:menu'); @@ -120,6 +121,14 @@ export default class MenuApp { menu.append(new MenuItem({type: 'separator'})); menu.append(new MenuItem({label: t('show_main_window')!, click: () => this.app.showMainWindow()})); menu.append(new MenuItem({label: t('preferences')!, click: () => this.app.showPreferencesWindow()})); + if (dev) menu.append(new MenuItem({label: 'Language', submenu: Menu.buildFromTemplate([ + ...this.app.i18n.options.supportedLngs || ['cimode'], + ].map(l => new MenuItem({ + label: languages[l as keyof typeof languages]?.name ?? l, + type: 'checkbox', + checked: (this.app.i18n.resolvedLanguage ?? this.app.i18n.language).toLowerCase() === l.toLowerCase(), + click: () => this.app.i18n.changeLanguage(l), + })))})); if (dev) menu.append(new MenuItem({label: 'Dump notifications state', click: () => { debug('Accounts', this.app.monitors.notifications.accounts); debug('Friends', this.app.monitors.notifications.onlinefriends); diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index 4f4f82c..accb355 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -82,6 +82,7 @@ const ipc = { registerEventListener: (event: string, listener: (args: any[]) => void) => events.on(event, listener), removeEventListener: (event: string, listener: (args: any[]) => void) => events.removeListener(event, listener), + getLanguage: () => language, getAccentColour: () => accent_colour, platform: process.platform, @@ -89,15 +90,20 @@ const ipc = { export type NxapiElectronIpc = typeof ipc; -ipcRenderer.on('nxapi:app:update-language', (e, l: string) => events.emit('update-language', l)); ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload()); ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintendo-accounts')); ipcRenderer.on('nxapi:discord:shouldrefresh', () => events.emit('update-discord-presence-source')); ipcRenderer.on('nxapi:discord:presence', (e, p: DiscordPresence) => events.emit('update-discord-presence', p)); ipcRenderer.on('nxapi:discord:user', (e, u: User) => events.emit('update-discord-user', u)); +let language: string | undefined = invSync('app:language'); +ipcRenderer.on('nxapi:app:update-language', (event, l: string) => { + language = l; + events.emit('update-language', l); +}); + let accent_colour: string | undefined = invSync('systemPreferences:accent-colour'); -ipcRenderer.on('nxapi:systemPreferences:accent-colour', (event, c) => { +ipcRenderer.on('nxapi:systemPreferences:accent-colour', (event, c: string) => { accent_colour = c; events.emit('systemPreferences:accent-colour', c); });