Merge branch 'i18n' of https://github.com/Inkception/nxapi into i18n

This commit is contained in:
Inkception 2023-01-30 03:11:17 +01:00
commit b0603e1e82
18 changed files with 220 additions and 90 deletions

19
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -53,7 +53,7 @@ function AddAccountManualPrompt(props: AddAccountManualPromptProps & {
</View>
<Text style={[styles.header, theme.text]}>{t('response_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('response_help_1', {url: 'npf{props.client_id}://auth'})}</Text>
<Text style={[styles.help, theme.text]}>{t('response_help_1', {url: `npf${props.client_id}://auth`})}</Text>
<Text style={[styles.help, theme.text]}>{t('response_help_2')}</Text>
<TextInput value={callback_url} onChangeText={setCallbackUrl}

View File

@ -0,0 +1,33 @@
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, Text } from 'react-native';
import { CurrentUser } from '../../../api/coral-types.js';
import ipc from '../ipc.js';
export default function FriendCode(props: {
friendcode: CurrentUser['links']['friendCode'];
} | {
id: string;
}) {
const friendcode = useMemo(() => '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 <Text
style={styles.friendCodeValue}
// @ts-expect-error react-native-web
onContextMenu={onFriendCodeContextMenu}
>SW-{friendcode.id}</Text>;
}
const styles = StyleSheet.create({
friendCodeValue: {
// @ts-expect-error
userSelect: 'all',
},
});

View File

@ -2,8 +2,13 @@ import React from 'react';
import { Platform, Text } from 'react-native';
import { svg_styles } from './util.js';
const IconWeb = React.memo(() => <Text>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style={svg_styles}><title>Add</title><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="32" d="M256 112v288M400 256H112"/></svg>
const IconWeb = React.memo((props: {
title?: string;
}) => <Text>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style={svg_styles}>
{props.title ? <title>{props.title}</title> : null}
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="32" d="M256 112v288M400 256H112"/>
</svg>
</Text>);
export default Platform.OS === 'web' ? IconWeb : React.memo(() => null);

View File

@ -2,8 +2,13 @@ import React from 'react';
import { Platform, Text } from 'react-native';
import { svg_styles } from './util.js';
const IconWeb = React.memo(() => <Text>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" style={svg_styles}><title>ionicons-v5-r</title><path d="M449.07,399.08,278.64,82.58c-12.08-22.44-44.26-22.44-56.35,0L51.87,399.08A32,32,0,0,0,80,446.25H420.89A32,32,0,0,0,449.07,399.08Zm-198.6-1.83a20,20,0,1,1,20-20A20,20,0,0,1,250.47,397.25ZM272.19,196.1l-5.74,122a16,16,0,0,1-32,0l-5.74-121.95v0a21.73,21.73,0,0,1,21.5-22.69h.21a21.74,21.74,0,0,1,21.73,22.7Z"/></svg>
const IconWeb = React.memo((props: {
title?: string;
}) => <Text>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" style={svg_styles}>
{props.title ? <title>{props.title}</title> : null}
<path d="M449.07,399.08,278.64,82.58c-12.08-22.44-44.26-22.44-56.35,0L51.87,399.08A32,32,0,0,0,80,446.25H420.89A32,32,0,0,0,449.07,399.08Zm-198.6-1.83a20,20,0,1,1,20-20A20,20,0,0,1,250.47,397.25ZM272.19,196.1l-5.74,122a16,16,0,0,1-32,0l-5.74-121.95v0a21.73,21.73,0,0,1,21.5-22.69h.21a21.74,21.74,0,0,1,21.73,22.7Z"/>
</svg>
</Text>);
export default Platform.OS === 'web' ? IconWeb : React.memo(() => null);

View File

@ -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';

View File

@ -48,7 +48,7 @@ export default function FriendWindow(props: FriendProps) {
if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) {
return <Root title={friend?.name} titleUser={user ?? undefined}
autoresize={!!user && discord_presence_source_state === RequestState.LOADED}
i18nNamespace="friend_window"
i18nNamespace={['friend_window', 'time_since']}
>
<View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
@ -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 <Root title={friend.name} titleUser={user} autoresize i18nNamespace="friend_window">
return <Root title={friend.name} titleUser={user} autoresize i18nNamespace={['friend_window', 'time_since']}>
<Friend
friend={friend} canSeeUserPresence={can_see_user_presence}
showDiscordPresenceSetup={discord_presence_active || !!friend.presence.updatedAt || false}
@ -160,7 +160,7 @@ function FriendPresence(props: {
const { t, i18n } = useTranslation('friend_window');
const logout = props.presence.logoutAt ? new Date(props.presence.logoutAt * 1000) : null;
const since_logout = useTimeSince(logout ?? new Date(0));
const since_logout = useTimeSince(logout ?? new Date(0), false, i18n.getFixedT(null, 'time_since'));
const game = 'name' in props.presence.game ? props.presence.game : null;
if ((props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) && game) {
@ -194,7 +194,12 @@ function FriendPresenceGame(props: {
<Text style={[styles.gameName, theme.text]}>{props.game.name}</Text>
{props.game.sysDescription ? <Text style={[styles.gameActivity, theme.text]}>{props.game.sysDescription}</Text> : null}
<Text style={[styles.gameTotalPlayTime, theme.text]}>
{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})}
</Text>
<Text style={[styles.gameFirstPlayed, theme.text]}>
{first_played ? t('game_first_played', {

View File

@ -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 = <TouchableOpacity onPress={showAddFriendModal} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><AddOutline /></Text>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><AddOutline title={t('add')!} /></Text>
</TouchableOpacity>;
const onFriendCodeContextMenu = useCallback(() => {
ipc.showFriendCodeMenu(props.user.nso!.nsoAccount.user.links.friendCode);
}, [ipc, props.user.nso?.nsoAccount.user.links.friendCode]);
const fc = <Text
style={styles.friendCodeValue}
// @ts-expect-error react-native-web
onContextMenu={onFriendCodeContextMenu}
>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
return <Section title={t('title')} loading={props.loading} error={props.error} headerButtons={header_buttons}>
{props.friends.length ? <ScrollView horizontal>
<View style={styles.content}>
@ -43,11 +34,19 @@ export default function Friends(props: {
</View>
</ScrollView> : <View style={styles.noFriends}>
<Text style={[styles.noFriendsText, theme.text]}>{t('no_friends')}</Text>
<Text style={[styles.noFriendsText, styles.noFriendsFriendCodeText, theme.text]}>{t('friend_code')}: {fc}</Text>
<Text style={[styles.noFriendsText, styles.noFriendsFriendCodeText, theme.text]}>
<Trans i18nKey="main_window:friends_section.friend_code">
<FriendCode friendcode={props.user.nso!.nsoAccount.user.links.friendCode} />
</Trans>
</Text>
</View>}
{props.friends.length ? <View style={styles.footer}>
<Text style={[styles.friendCode, theme.text]}>{t('friend_code')}: {fc}</Text>
<Text style={[styles.friendCode, theme.text]}>
<Trans i18nKey="main_window:friends_section.friend_code">
<FriendCode friendcode={props.user.nso!.nsoAccount.user.links.friendCode} />
</Trans>
</Text>
</View> : null}
</Section>;
}
@ -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 <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>{t('presence_playing')}</Text>;
@ -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,

View File

@ -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']}
>
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId}
insetTitleBarControls={props.insetTitleBarControls}

View File

@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useAccentColour, useColourScheme } from '../util.js';
import { useTranslation } from 'react-i18next';
import { BORDER_COLOUR_LIGHT, BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc from '../ipc.js';
import Warning from '../components/icons/warning.js';
@ -13,6 +14,7 @@ export default function Section(props: React.PropsWithChildren<{
}>) {
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 ? <ActivityIndicator style={styles.activityIndicator} size={HEADER_SIZE}
color={'#' + accent_colour} /> :
props.error ? <TouchableOpacity onPress={showErrorDetails} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><Warning /></Text>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><Warning title={t('section_error')!} /></Text>
</TouchableOpacity> : null}
{props.headerButtons}
</View>

View File

@ -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)),
];
}
}

View File

@ -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]();
}

View File

@ -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></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',

View File

@ -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,
});
}

View File

@ -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),

View File

@ -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);

View File

@ -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);
});