App translation support

https://github.com/samuelthomas2774/nxapi/issues/40
This commit is contained in:
Samuel Elliott 2023-01-27 21:33:55 +00:00
parent 27e41f7735
commit 0b7e5b618f
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
31 changed files with 1618 additions and 755 deletions

151
package-lock.json generated
View File

@ -57,8 +57,11 @@
"@types/yargs": "^17.0.14",
"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",
"react-native-web": "^0.17.7",
"rollup": "^2.79.1",
"rollup-plugin-polyfill-node": "^0.10.2",
@ -66,6 +69,18 @@
"typescript": "^4.9.3"
}
},
"node_modules/@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.13.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@ -2684,6 +2699,15 @@
"node": ">=10"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dev": true,
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
@ -2738,6 +2762,38 @@
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==",
"dev": true
},
"node_modules/i18next": {
"version": "22.4.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.6.tgz",
"integrity": "sha512-9Tm1ezxWyzV+306CIDMBbYBitC1jedQyYuuLtIv7oxjp2ohh8eyxP9xytIf+2bbQfhH784IQKPSYp+Zq9+YSbw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@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",
@ -3605,6 +3661,28 @@
"react": "17.0.2"
}
},
"node_modules/react-i18next": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-12.1.1.tgz",
"integrity": "sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.14.5",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 19.0.0",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -3672,6 +3750,12 @@
"integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==",
"dev": true
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true
},
"node_modules/register-scheme": {
"version": "0.0.2",
"resolved": "git+ssh://git@github.com/devsnek/node-register-scheme.git#e7cc9a63a1f512565da44cb57316d9fb10750e17",
@ -4507,6 +4591,15 @@
"node": ">=0.6.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
@ -4644,6 +4737,15 @@
}
},
"dependencies": {
"@babel/runtime": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz",
"integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@ -6727,6 +6829,15 @@
"lru-cache": "^6.0.0"
}
},
"html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dev": true,
"requires": {
"void-elements": "3.1.0"
}
},
"http-cache-semantics": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
@ -6772,6 +6883,24 @@
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==",
"dev": true
},
"i18next": {
"version": "22.4.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.6.tgz",
"integrity": "sha512-9Tm1ezxWyzV+306CIDMBbYBitC1jedQyYuuLtIv7oxjp2ohh8eyxP9xytIf+2bbQfhH784IQKPSYp+Zq9+YSbw==",
"dev": true,
"requires": {
"@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",
@ -7409,6 +7538,16 @@
"scheduler": "^0.20.2"
}
},
"react-i18next": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-12.1.1.tgz",
"integrity": "sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.14.5",
"html-parse-stringify": "^3.0.1"
}
},
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -7465,6 +7604,12 @@
}
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true
},
"register-scheme": {
"version": "git+ssh://git@github.com/devsnek/node-register-scheme.git#e7cc9a63a1f512565da44cb57316d9fb10750e17",
"from": "register-scheme@github:devsnek/node-register-scheme",
@ -8090,6 +8235,12 @@
"extsprintf": "^1.2.0"
}
},
"void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"dev": true
},
"web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",

View File

@ -80,8 +80,11 @@
"@types/yargs": "^17.0.14",
"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",
"react-native-web": "^0.17.7",
"rollup": "^2.79.1",
"rollup-plugin-polyfill-node": "^0.10.2",

View File

@ -1,67 +1,79 @@
import React, { useCallback, useState } from 'react';
import { StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { Root, useEventListener } from '../util.js';
import { Root, useAccentColour, useColourScheme, useEventListener } from '../util.js';
export interface AddAccountManualPromptProps {
authoriseurl: string;
client_id: string;
}
export default function AddAccountManualPrompt(props: AddAccountManualPromptProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
export default function AddAccountManualPromptWindow(props: AddAccountManualPromptProps) {
useEventListener(events, 'window:refresh', () => true, []);
const save = useCallback((callback_url: string) => {
location.href = callback_url;
}, []);
return <Root
title={i18n => i18n.t('addaccountmanual_window:title')} scrollable autoresize
i18nNamespace="addaccountmanual_window"
>
<AddAccountManualPrompt {...props} save={save} />
</Root>
}
function AddAccountManualPrompt(props: AddAccountManualPromptProps & {
save?: (callback_url: string) => void;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('addaccountmanual_window');
const [callback_url, setCallbackUrl] = useState('');
const callback_url_valid = callback_url.startsWith('npf' + props.client_id + '://auth');
const save = useCallback(() => {
if (callback_url_valid) {
location.href = callback_url;
props.save?.call(null, callback_url);
}
}, [callback_url, callback_url_valid]);
}, [props.save, callback_url, callback_url_valid]);
return <Root title="Add account" scrollable autoresize>
<View style={styles.main}>
<Text style={theme.text}>1. Login to your Nintendo Account</Text>
<Text style={[styles.help, theme.text]}>Do not select an account yet.</Text>
return <View style={styles.main}>
<Text style={theme.text}>{t('authorise_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('authorise_help')}</Text>
<View style={styles.buttonSingle}>
<Button title="Open Nintendo Account authorisation"
onPress={() => ipc.openExternalUrl(props.authoriseurl)}
<View style={styles.buttonSingle}>
<Button title={t('authorise_open')}
onPress={() => ipc.openExternalUrl(props.authoriseurl)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</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_2')}</Text>
<TextInput value={callback_url} onChangeText={setCallbackUrl}
placeholder={'npf' + props.client_id + '://auth#...'}
style={[styles.textInput, theme.textInput]} />
<View style={styles.buttons}>
<View style={styles.button}>
<Button title={t('cancel')}
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<Text style={[styles.header, theme.text]}>2. Enter the callback link</Text>
<Text style={[styles.help, theme.text]}>On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf{props.client_id}://auth".</Text>
<Text style={[styles.help, theme.text]}>If you are adding a child account linked to your account, click "Select this person" next to their account to sign in as the child account, then with only the child account showing right click "Select this person" and copy the link.</Text>
<TextInput value={callback_url} onChangeText={setCallbackUrl}
placeholder={'npf' + props.client_id + '://auth#...'}
style={[styles.textInput, theme.textInput]} />
<View style={styles.buttons}>
<View style={styles.button}>
<Button title="Cancel"
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
{callback_url_valid ? <View style={styles.button}>
<Button title="Add account"
onPress={save}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View> : null}
</View>
{callback_url_valid ? <View style={styles.button}>
<Button title={t('save')}
onPress={save}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View> : null}
</View>
</Root>;
</View>;
}
const styles = StyleSheet.create({

View File

@ -1,11 +1,13 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, NativeSyntheticEvent, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, useColorScheme, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, NativeSyntheticEvent, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
import { Friend, FriendCodeUser } from '../../../api/coral-types.js';
import { SavedToken } from '../../../common/auth/coral.js';
import Warning from '../components/icons/warning.js';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { RequestState, Root, useAsync, useEventListener } from '../util.js';
import ipc from '../ipc.js';
import { RequestState, Root, useAccentColour, useAsync, useColourScheme } from '../util.js';
export interface AddFriendProps {
user: string;
@ -27,12 +29,8 @@ type SendFriendRequestStateArray =
const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/;
const FRIEND_CODE_URL = /^(?!https\:\/\/lounge\.nintendo\.com\/|com\.nintendo\.znca\:\/\/znca\/)friendcode\/(\d{4}-\d{4}-\d{4})\//;
export default function AddFriend(props: AddFriendProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
export default function AddFriendWindow(props: AddFriendProps) {
const accent_colour = useAccentColour();
const [token] = useAsync(useCallback(() => ipc.getNintendoAccountCoralToken(props.user), [ipc, props.user]));
const [user] = useAsync(useCallback(() => token ?
@ -42,21 +40,6 @@ export default function AddFriend(props: AddFriendProps) {
const is_valid_friendcode = FRIEND_CODE.test(friendcode);
const show_friendcode_field = !props.friendcode || !FRIEND_CODE.test(props.friendcode);
const onChangeFriendCode = useCallback((event: NativeSyntheticEvent<TextInputChangeEventData>) => {
let match;
if (match = event.nativeEvent.text.match(FRIEND_CODE_URL)) {
setFriendCode(match[1]);
} else {
const friendcode = event.nativeEvent.text
.replace(/[^0-9]/g, '')
.replace(/^([0-9]{4})/g, '$1-')
.replace(/^([0-9]{4}-[0-9]{4})/g, '$1-')
.substr(0, 14);
setFriendCode(friendcode);
}
}, []);
const [target_user, lookup_error, lookup_state] = useAsync(useCallback(() => token && is_valid_friendcode ?
ipc.getNsoUserByFriendCode(token, friendcode) : Promise.resolve(null),
[ipc, token, friendcode, is_valid_friendcode]));
@ -65,25 +48,85 @@ export default function AddFriend(props: AddFriendProps) {
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === target_user?.nsaId);
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
if (!user || !token || (!show_friendcode_field && lookup_state === RequestState.LOADING)) {
return <Root
title={i18n => i18n.t('addfriend_window.title')} titleUser={user ?? undefined} autoresize={!!user}
i18nNamespace="addfriend_window"
>
<View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</Root>;
}
return <Root
title={i18n => i18n.t('addfriend_window.title')} titleUser={user} autoresize
i18nNamespace="addfriend_window"
>
<AddFriend
user={user} token={token}
friendcode={friendcode} setFriendCode={show_friendcode_field ? setFriendCode : undefined}
lookupState={lookup_state} lookupUser={target_user} lookupError={lookup_error}
friendsState={friends_state} lookupFriend={friend}
/>
</Root>;
}
function AddFriend(props: {
user: SavedToken;
token: string;
friendcode: string;
setFriendCode?: (friendcode: string) => void;
lookupState: RequestState;
lookupUser: FriendCodeUser | null;
lookupError: Error | null;
friendsState: RequestState;
lookupFriend?: Friend;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('addfriend_window');
const onChangeFriendCode = useCallback((event: NativeSyntheticEvent<TextInputChangeEventData>) => {
let match;
if (match = event.nativeEvent.text.match(FRIEND_CODE_URL)) {
props.setFriendCode?.(match[1]);
} else {
const friendcode = event.nativeEvent.text
.replace(/[^0-9]/g, '')
.replace(/^([0-9]{4})/g, '$1-')
.replace(/^([0-9]{4}-[0-9]{4})/g, '$1-')
.substr(0, 14);
props.setFriendCode?.(friendcode);
}
}, []);
const showLookupErrorDetails = useCallback(() => {
alert(lookup_error);
}, [lookup_error]);
alert(props.lookupError);
}, [props.lookupError]);
const [send_state, setSendFriendRequestState] = useState<SendFriendRequestStateArray>([SendFriendRequestState.NOT_LOADING]);
const sendFriendRequest = useCallback(async () => {
if (send_state[0] === SendFriendRequestState.SENDING) return;
if (!token || !target_user) return;
setSendFriendRequestState([SendFriendRequestState.SENDING, target_user, friendcode]);
if (!props.lookupUser) return;
setSendFriendRequestState([SendFriendRequestState.SENDING, props.lookupUser, props.friendcode]);
try {
const {result, friend} = await ipc.addNsoFriend(token, target_user.nsaId);
const {result, friend} = await ipc.addNsoFriend(props.token, props.lookupUser.nsaId);
setSendFriendRequestState([SendFriendRequestState.SENT, target_user, friendcode, friend]);
setSendFriendRequestState([SendFriendRequestState.SENT, props.lookupUser, props.friendcode, friend]);
} catch (err) {
setSendFriendRequestState([SendFriendRequestState.ERROR, target_user, friendcode, err as Error]);
setSendFriendRequestState([SendFriendRequestState.ERROR, props.lookupUser, props.friendcode, err as Error]);
}
}, [token, target_user, friendcode, send_state]);
}, [props.token, props.lookupUser, props.friendcode, send_state]);
const showSendFriendRequestErrorDetails = useCallback(() => {
if (send_state[0] !== SendFriendRequestState.ERROR) return;
@ -93,100 +136,86 @@ export default function AddFriend(props: AddFriendProps) {
const onFriendCodeKeyPress = useCallback((event: NativeSyntheticEvent<TextInputKeyPressEventData>) =>
event.nativeEvent.key === 'Escape' && window.close(), []);
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
return <View style={styles.main}>
{props.setFriendCode ? <>
<Text style={theme.text}>{t('title')}</Text>
if (!user || (!show_friendcode_field && lookup_state === RequestState.LOADING)) {
return <Root title="Add friend" titleUser={user ?? undefined} autoresize={!!user}>
<View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
<Text style={[styles.help, theme.text]}>{t('help')}</Text>
<View style={styles.friendCodeInputContainer}>
<TextInput value={props.friendcode} onChange={onChangeFriendCode}
onKeyPress={onFriendCodeKeyPress}
placeholder="0000-0000-0000"
style={[styles.textInput, styles.friendCodeInput, theme.textInput]} />
{props.lookupState === RequestState.LOADING ?
<ActivityIndicator style={styles.activityIndicator} size={20} color={'#' + accent_colour} /> :
props.lookupError ? <TouchableOpacity onPress={showLookupErrorDetails} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><Warning /></Text>
</TouchableOpacity> : null}
</View>
</Root>;
}
</> : props.lookupError ? <TouchableOpacity onPress={showLookupErrorDetails}>
<Text style={[styles.lookupErrorNoFriendCodeField, theme.text]}>
<Text style={[styles.lookupErrorIcon, {color: '#' + accent_colour}]}><Warning /></Text>
{t('lookup_error', {message: props.lookupError.name + ' ' + props.lookupError.message})}
</Text>
</TouchableOpacity> : null}
return <Root title="Add friend" titleUser={user} scrollable autoresize>
<View style={styles.main}>
{show_friendcode_field ? <>
<Text style={theme.text}>Add friend</Text>
{props.lookupUser ? <View style={[
styles.targetUser,
!props.setFriendCode ? styles.targetUserNoFriendCodeField : null,
theme.targetUser,
]}>
<Image source={{uri: props.lookupUser.imageUri, width: 100, height: 100}} style={styles.targetUserImage} />
<Text style={[styles.help, theme.text]}>Type or paste a friend code or friend code URL to send a friend request.</Text>
<View style={styles.targetUserDetail}>
<Text style={[styles.targetUserName, theme.text]}>{props.lookupUser.name}</Text>
<View style={styles.friendCodeInputContainer}>
<TextInput value={friendcode} onChange={onChangeFriendCode}
onKeyPress={onFriendCodeKeyPress}
placeholder="0000-0000-0000"
style={[styles.textInput, styles.friendCodeInput, theme.textInput]} />
{lookup_state === RequestState.LOADING ?
<ActivityIndicator style={styles.activityIndicator} size={20} color={'#' + accent_colour} /> :
lookup_error ? <TouchableOpacity onPress={showLookupErrorDetails} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><Warning /></Text>
</TouchableOpacity> : null}
</View>
</> : lookup_error ? <TouchableOpacity onPress={showLookupErrorDetails}>
<Text style={[styles.lookupErrorNoFriendCodeField, theme.text]}>
<Text style={[styles.lookupErrorIcon, {color: '#' + accent_colour}]}><Warning /></Text>
Error looking up friend code: {lookup_error.name} {lookup_error.message}
<Text style={[styles.targetUserNsaId, theme.text]}>
{t('nsa_id')}: <Text style={styles.targetUserNsaIdValue}>{props.lookupUser.nsaId}</Text>
</Text>
</TouchableOpacity> : null}
<Text style={[styles.targetUserCoralId, theme.text]}>{props.lookupUser.id ? <>
{t('coral_id')}: <Text style={styles.targetUserCoralIdValue}>{props.lookupUser.id}</Text>
</> : t('no_coral_user')}</Text>
{target_user ? <View style={[
styles.targetUser,
!show_friendcode_field ? styles.targetUserNoFriendCodeField : null,
theme.targetUser,
]}>
<Image source={{uri: target_user.imageUri, width: 100, height: 100}} style={styles.targetUserImage} />
<View style={styles.targetUserDetail}>
<Text style={[styles.targetUserName, theme.text]}>{target_user.name}</Text>
<Text style={[styles.targetUserNsaId, theme.text]}>NSA ID: <Text style={styles.targetUserNsaIdValue}>{target_user.nsaId}</Text></Text>
<Text style={[styles.targetUserCoralId, theme.text]}>{target_user.id ? <>
Coral user ID: <Text style={styles.targetUserCoralIdValue}>{target_user.id}</Text>
</> : 'Never used Nintendo Switch Online app'}</Text>
{send_state[0] === SendFriendRequestState.SENT && send_state[3] ?
<Text style={[styles.friendRequestState, theme.text]}>You are now friends with this user.</Text> :
send_state[0] === SendFriendRequestState.SENT ?
<Text style={[styles.friendRequestState, theme.text]}>Friend request sent. {target_user.name} can accept your friend request using a Nintendo Switch console, or by sending you a friend request using the Nintendo Switch Online app or nxapi.</Text> :
send_state[0] === SendFriendRequestState.SENDING ?
{send_state[0] === SendFriendRequestState.SENT && send_state[3] ?
<Text style={[styles.friendRequestState, theme.text]}>{t('send_added')}</Text> :
send_state[0] === SendFriendRequestState.SENT ?
<Text style={[styles.friendRequestState, theme.text]}>{t('send_sent', {user: props.lookupUser.name})}</Text> :
send_state[0] === SendFriendRequestState.SENDING ?
<Text style={[styles.friendRequestState, theme.text]}>
<ActivityIndicator style={styles.friendRequestActivityIndicator} color={'#' + accent_colour} />
{t('send_sending')}
</Text> :
send_state[0] === SendFriendRequestState.ERROR ?
<TouchableOpacity onPress={showSendFriendRequestErrorDetails}>
<Text style={[styles.friendRequestState, theme.text]}>
<ActivityIndicator style={styles.friendRequestActivityIndicator} color={'#' + accent_colour} />
Sending friend request...
</Text> :
send_state[0] === SendFriendRequestState.ERROR ?
<TouchableOpacity onPress={showSendFriendRequestErrorDetails}>
<Text style={[styles.friendRequestState, theme.text]}>
<Text style={[styles.friendRequestStateIcon, {color: '#' + accent_colour}]}><Warning /></Text>
Error sending friend request: {send_state[3].name} {send_state[3].message}
</Text>
</TouchableOpacity> :
friend ?
<Text style={[styles.friendRequestState, theme.text]}>You are already friends with this user.</Text> : null}
</View>
</View> : null}
<View style={styles.buttons}>
<View style={styles.button}>
<Button title="Close"
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
{lookup_state === RequestState.LOADED && target_user &&
friends_state === RequestState.LOADED && !friend &&
target_user?.nsaId !== user.nsoAccount.user.nsaId ? <View style={styles.button}>
<Button title="Send friend request"
onPress={sendFriendRequest}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View> : null}
<Text style={[styles.friendRequestStateIcon, {color: '#' + accent_colour}]}><Warning /></Text>
{t('send_error', {message: send_state[3].name + ' ' + send_state[3].message})}
</Text>
</TouchableOpacity> :
props.lookupFriend ?
<Text style={[styles.friendRequestState, theme.text]}>{t('already_friends')}</Text> : null}
</View>
</View> : null}
<View style={styles.buttons}>
<View style={styles.button}>
<Button title={t('close')}
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
{props.lookupState === RequestState.LOADED && props.lookupUser &&
props.friendsState === RequestState.LOADED && !props.lookupFriend &&
props.lookupUser?.nsaId !== props.user.nsoAccount.user.nsaId ? <View style={styles.button}>
<Button title={t('send')}
onPress={sendFriendRequest}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View> : null}
</View>
</Root>;
</View>;
}
const styles = StyleSheet.create({

View File

@ -1 +1,3 @@
export { default as Button } from './button.js';
export { default as NintendoSwitchUser } from './nintendo-switch-user.js';
export { NintendoSwitchUsers } from './nintendo-switch-user.js';

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Image, StyleSheet } from 'react-native';
import { CurrentUser, Friend } from '../../../api/coral-types.js';
export default function NintendoSwitchUser(props: {
friend: Friend;
nickname?: string;
} | {
user: CurrentUser;
nickname?: string;
}) {
const user = 'friend' in props ? props.friend : props.user;
return <>
<Image source={{uri: user.imageUri, width: 16, height: 16}}
style={styles.userImage} />
{' '}
{user.name}
{props.nickname && user.name !== props.nickname ? ' (' + props.nickname + ')' : ''}
</>;
}
export function NintendoSwitchUsers(props: {
users: Parameters<typeof NintendoSwitchUser>[0][];
}) {
return <>
{props.users.map((u, i) => <React.Fragment key={'friend' in u ? u.friend.nsaId : u.user.nsaId}>
{i === 0 ? '' : ', '}
<NintendoSwitchUser {...u} />
</React.Fragment>)}
</>;
}
const styles = StyleSheet.create({
userImage: {
borderRadius: 8,
textAlignVertical: -3,
},
});

View File

@ -1,10 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { Picker } from 'react-native-web';
import { DiscordPresenceSource } from '../../common/types.js';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, Root, useAsync, useDiscordPresenceSource, useEventListener } from '../util.js';
import { getAccounts, RequestState, Root, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener, User } from '../util.js';
export interface DiscordSetupProps {
users?: string[];
@ -19,39 +21,68 @@ enum DiscordSourceType {
NONE,
}
export default function DiscordSetup(props: DiscordSetupProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
export default function DiscordSetupWindow(props: DiscordSetupProps) {
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
useEventListener(events, 'window:refresh', () => forceRefreshAccounts(), []);
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const [selectedMode, setSelectedMode] = useState(props.users?.length && props.friend_nsa_id ?
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
if (!users || discord_presence_source_state !== RequestState.LOADED) {
return null;
}
return <Root
title={i18n => i18n.t('discordsetup_window:title')} scrollable autoresize
i18nNamespace="discordsetup_window"
>
<DiscordSetup
users={users} showUsers={props.users}
friendNsaId={props.friend_nsa_id}
discordPresenceSource={discord_presence_source}
showPreferencesButton={props.show_preferences_button}
/>
</Root>;
}
function DiscordSetup(props: {
users: User[];
showUsers?: string[];
friendNsaId?: string;
discordPresenceSource: DiscordPresenceSource | null;
showPreferencesButton?: boolean;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('discordsetup_window');
const [selectedMode, setSelectedMode] = useState(props.users?.length && props.friendNsaId ?
DiscordSourceType.CORAL : DiscordSourceType.NONE);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedFriendNsaId, setSelectedFriendNsaId] = useState<string | null>(props.friend_nsa_id ?? null);
const [selectedFriendNsaId, setSelectedFriendNsaId] = useState<string | null>(props.friendNsaId ?? null);
const [presenceUrl, setPresenceUrl] = useState('');
useEffect(() => {
if (!discord_presence_source) {
setSelectedMode(props.friend_nsa_id ? DiscordSourceType.CORAL : DiscordSourceType.NONE);
} else if ('na_id' in discord_presence_source) {
if (!props.discordPresenceSource) {
setSelectedMode(props.friendNsaId ? DiscordSourceType.CORAL : DiscordSourceType.NONE);
} else if ('na_id' in props.discordPresenceSource) {
setSelectedMode(DiscordSourceType.CORAL);
setSelectedUserId(discord_presence_source.na_id);
setSelectedFriendNsaId(discord_presence_source.friend_nsa_id ?? null);
} else if ('url' in discord_presence_source) {
setSelectedUserId(props.discordPresenceSource.na_id);
setSelectedFriendNsaId(props.discordPresenceSource.friend_nsa_id ?? null);
} else if ('url' in props.discordPresenceSource) {
setSelectedMode(DiscordSourceType.URL);
setPresenceUrl(discord_presence_source.url);
setPresenceUrl(props.discordPresenceSource.url);
}
}, [discord_presence_source]);
}, [props.discordPresenceSource]);
const user = useMemo(() => selectedUserId ? users?.find(u => u.user.id === selectedUserId) : undefined,
[selectedUserId, users]);
const user = useMemo(() => selectedUserId ? props.users?.find(u => u.user.id === selectedUserId) : undefined,
[selectedUserId, props.users]);
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => user?.nsotoken ?
ipc.getNsoFriends(user.nsotoken) : Promise.resolve(null), [ipc, user?.nsotoken]));
const friends_with_presence = useMemo(() => friends?.filter(f => f.presence.updatedAt ||
@ -59,10 +90,11 @@ export default function DiscordSetup(props: DiscordSetupProps) {
const friend = useMemo(() => selectedFriendNsaId ? friends?.find(f => f.nsaId === selectedFriendNsaId) : undefined,
[selectedFriendNsaId, friends]);
const filtered_users = useMemo(() => users?.filter(u => selectedUserId === u.user.id ||
(u.nso && (!props.users || props.users.includes(u.user.id)))), [users, props.users, selectedUserId]);
const filtered_users = useMemo(() => props.users.filter(u => selectedUserId === u.user.id ||
(u.nso && (!props.showUsers || props.showUsers.includes(u.user.id)))),
[props.users, props.showUsers, selectedUserId]);
useEventListener(events, 'window:refresh', () => (forceRefreshAccounts(), forceRefreshFriends()), []);
useEventListener(events, 'window:refresh', () => forceRefreshFriends(), []);
useEffect(() => {
if (filtered_users?.length && !user) setSelectedUserId(filtered_users[0].user.id);
@ -87,103 +119,89 @@ export default function DiscordSetup(props: DiscordSetupProps) {
window.close();
}, [selectedMode, selectedUserId, selectedFriendNsaId, presenceUrl]);
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
return <View style={styles.main}>
<Text style={theme.text}>{t('title')}</Text>
if (!users || discord_presence_source_state !== RequestState.LOADED) {
return null;
}
<Text style={[styles.header, theme.text]}>{t('mode_heading')}</Text>
return <Root title="Discord Rich Presence setup" scrollable autoresize>
<View style={styles.main}>
<Text style={theme.text}>Discord Rich Presence setup</Text>
<Picker<DiscordSourceType> selectedValue={selectedMode} onValueChange={v => setSelectedMode(parseInt(v))}
style={[styles.picker, theme.picker]}
>
<Picker.Item label={t('mode_coral_friend')!} value={DiscordSourceType.CORAL} />
<Picker.Item label={t('mode_url')!} value={DiscordSourceType.URL} />
<Picker.Item label={t('mode_none')!} value={DiscordSourceType.NONE} />
</Picker>
<Text style={[styles.header, theme.text]}>1. Select mode</Text>
{selectedMode === DiscordSourceType.CORAL ? <>
<Text style={[styles.header, theme.text]}>{t('coral_user_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('coral_user_help')}</Text>
<Picker<DiscordSourceType> selectedValue={selectedMode} onValueChange={v => setSelectedMode(parseInt(v))}
<Picker<string> selectedValue={selectedUserId ?? ''} onValueChange={setSelectedUserId}
style={[styles.picker, theme.picker]}
>
<Picker.Item label="Select a user that is friends with the user you want to share"
value={DiscordSourceType.CORAL} />
<Picker.Item label="Enter a URL that returns the presence data you want to share"
value={DiscordSourceType.URL} />
<Picker.Item label="Disable" value={DiscordSourceType.NONE} />
{filtered_users?.map(u => <Picker.Item
key={u.user.id}
label={u.user.nickname +
(u.user.nickname !== u.nso!.nsoAccount.user.name ? '/' + u.nso!.nsoAccount.user.name : '')}
value={u.user.id}
/>)}
</Picker>
{selectedMode === DiscordSourceType.CORAL ? <>
<Text style={[styles.header, theme.text]}>2. Select user</Text>
<Text style={[styles.help, theme.text]}>This user must be friends with the user you want to share.</Text>
{props.friendNsaId && (!selectedFriendNsaId || selectedFriendNsaId === props.friendNsaId) ? <>
<Text style={[styles.header, theme.text]}>{t('coral_friend_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('coral_friend_help')}</Text>
<Picker<string> selectedValue={selectedUserId ?? ''} onValueChange={setSelectedUserId}
<Picker<string> selectedValue={selectedFriendNsaId ?? ''} onValueChange={setSelectedFriendNsaId}
style={[styles.picker, theme.picker]}
enabled={false}
>
<Picker.Item key={props.friendNsaId} label={friend?.name ?? props.friendNsaId}
value={props.friendNsaId} />
</Picker>
</> : user && friends_with_presence ? <>
<Text style={[styles.header, theme.text]}>{t('coral_friend_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('coral_friend_help')}</Text>
<Picker<string> selectedValue={selectedFriendNsaId ?? ''} onValueChange={setSelectedFriendNsaId}
style={[styles.picker, theme.picker]}
>
{filtered_users?.map(u => <Picker.Item
key={u.user.id}
label={u.user.nickname +
(u.user.nickname !== u.nso!.nsoAccount.user.name ? '/' + u.nso!.nsoAccount.user.name : '')}
value={u.user.id}
/>)}
{friends_with_presence.map(f => <Picker.Item key={f.nsaId} label={f.name} value={f.nsaId} />)}
</Picker>
{props.friend_nsa_id && (!selectedFriendNsaId || selectedFriendNsaId === props.friend_nsa_id) ? <>
<Text style={[styles.header, theme.text]}>3. Select friend</Text>
<Text style={[styles.help, theme.text]}>This is the user you want to share.</Text>
<Picker<string> selectedValue={selectedFriendNsaId ?? ''} onValueChange={setSelectedFriendNsaId}
style={[styles.picker, theme.picker]}
enabled={false}
>
<Picker.Item key={props.friend_nsa_id} label={friend?.name ?? props.friend_nsa_id}
value={props.friend_nsa_id} />
</Picker>
</> : user && friends_with_presence ? <>
<Text style={[styles.header, theme.text]}>3. Select friend</Text>
<Text style={[styles.help, theme.text]}>This is the user you want to share.</Text>
<Picker<string> selectedValue={selectedFriendNsaId ?? ''} onValueChange={setSelectedFriendNsaId}
style={[styles.picker, theme.picker]}
>
{friends_with_presence.map(f => <Picker.Item key={f.nsaId} label={f.name} value={f.nsaId} />)}
</Picker>
</> : null}
</> : null}
</> : null}
{selectedMode === DiscordSourceType.URL ? <>
<Text style={[styles.header, theme.text]}>2. Enter presence URL</Text>
<Text style={[styles.help, theme.text]}>This must be a HTTPS URL that returns a JSON object with either a user, friend or presence key. This is intended to be used with nxapi's znc API proxy.</Text>
{selectedMode === DiscordSourceType.URL ? <>
<Text style={[styles.header, theme.text]}>{t('url_heading')}</Text>
<Text style={[styles.help, theme.text]}>{t('url_help')}</Text>
<TextInput value={presenceUrl} onChangeText={setPresenceUrl}
placeholder="https://nxapi.example.com/api/znc/friend/..."
style={[styles.textInput, theme.textInput]} />
</> : null}
<TextInput value={presenceUrl} onChangeText={setPresenceUrl}
placeholder="https://nxapi.example.com/api/znc/friend/..."
style={[styles.textInput, theme.textInput]} />
</> : null}
{discord_presence_source && (props.show_preferences_button ?? true) ? <>
<Text style={[styles.help, styles.header, theme.text]}>Configure additional options for Discord Rich Presence</Text>
<View style={[styles.button, styles.buttonPreferences]}>
<Button title="Preferences"
onPress={() => ipc.showPreferencesWindow()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</> : null}
{props.discordPresenceSource && (props.showPreferencesButton ?? true) ? <>
<Text style={[styles.help, styles.header, theme.text]}>{t('preferences_heading')}</Text>
<View style={[styles.button, styles.buttonPreferences]}>
<Button title={t('preferences')!}
onPress={() => ipc.showPreferencesWindow()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</> : null}
<View style={styles.buttons}>
<View style={styles.button}>
<Button title="Cancel"
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<View style={styles.button}>
<Button title="Save"
onPress={save}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<View style={styles.buttons}>
<View style={styles.button}>
<Button title={t('cancel')!}
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<View style={styles.button}>
<Button title={t('save')!}
onPress={save}
primary
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</View>
</Root>;
</View>;
}
const styles = StyleSheet.create({

View File

@ -1,9 +1,10 @@
/// <reference path="../react-native-web.d.ts" />
import React, { useCallback, useEffect } from 'react';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { CheckBox } from 'react-native-web';
import { Game, Presence, PresencePermissions, PresenceState } from '../../../api/coral-types.js';
import { Friend, Game, Presence, PresencePermissions, PresenceState } from '../../../api/coral-types.js';
import { getTitleIdFromEcUrl, hrduration } from '../../../util/misc.js';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
@ -15,10 +16,7 @@ export interface FriendProps {
friend: string;
}
export default function Friend(props: FriendProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
export default function FriendWindow(props: FriendProps) {
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
@ -50,6 +48,7 @@ export default function Friend(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"
>
<View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
@ -63,75 +62,114 @@ export default function Friend(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>
<View style={styles.main}>
<View style={styles.friend}>
<Image source={{uri: friend.imageUri, width: 130, height: 130}} style={styles.friendImage} />
<Text style={[styles.friendName, theme.text]}>{friend.name}</Text>
return <Root title={friend.name} titleUser={user} autoresize i18nNamespace="friend_window">
<Friend
friend={friend} canSeeUserPresence={can_see_user_presence}
showDiscordPresenceSetup={discord_presence_active || !!friend.presence.updatedAt || false}
discordPresenceActive={discord_presence_active ?? false}
setDiscordPresenceActive={active => ipc.setDiscordPresenceSource(active ?
{na_id: user.user.id, friend_nsa_id: friend.nsaId} : null)} />
</Root>;
}
{friend.presence.updatedAt ?
<FriendPresence presence={friend.presence} /> :
<Text style={[styles.noPresence, theme.text]}>You don't have access to this user's presence, or they have never been online.</Text>}
function Friend(props: {
friend: Friend;
canSeeUserPresence?: boolean;
showDiscordPresenceSetup?: boolean;
discordPresenceActive?: boolean;
setDiscordPresenceActive?: (active: boolean) => void;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('friend_window');
const friend = props.friend;
return <View style={styles.main}>
<View style={styles.friend}>
<Image source={{uri: friend.imageUri, width: 130, height: 130}} style={styles.friendImage} />
<Text style={[styles.friendName, theme.text]}>{friend.name}</Text>
{friend.presence.updatedAt ?
<FriendPresence presence={friend.presence} /> :
<Text style={[styles.noPresence, theme.text]}>{t('no_presence')}</Text>}
</View>
<View style={styles.right}>
<View style={styles.detail}>
{(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
'name' in friend.presence.game ? <FriendPresenceGame game={friend.presence.game} /> : null}
<Text style={[styles.friendNsaId, theme.text]}>
{t('nsa_id')}: <Text style={styles.friendNsaIdValue}>{friend.nsaId}</Text>
</Text>
<Text style={[styles.friendCoralId, theme.text]}>{friend.isServiceUser ? <>
{t('coral_id')}: <Text style={styles.friendCoralIdValue}>{friend.id}</Text>
</> : t('no_coral_user')}</Text>
<Text style={[styles.friendCreatedAt, theme.text]}>
{t('friends_since', {
date: new Date(friend.friendCreatedAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})}
</Text>
{friend.presence.updatedAt ? <Text style={[styles.presenceUpdatedAt, theme.text]}>
{t('presence_updated_at', {
date: new Date(friend.presence.updatedAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})}
</Text> : null}
{!(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
friend.presence.logoutAt ? <Text style={[styles.presenceUpdatedAt, theme.text]}>
{t('presence_logout_at', {
date: new Date(friend.presence.logoutAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})}
</Text> : null}
<Text style={[styles.canSeeUserPresence, theme.text]}>
{t(props.canSeeUserPresence ? 'presence_sharing' : 'presence_not_sharing')}
</Text>
</View>
<View style={styles.right}>
<View style={styles.detail}>
{(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
'name' in friend.presence.game ? <FriendPresenceGame game={friend.presence.game} /> : null}
<Text style={[styles.friendNsaId, theme.text]}>NSA ID: <Text style={styles.friendNsaIdValue}>{friend.nsaId}</Text></Text>
<Text style={[styles.friendCoralId, theme.text]}>{friend.isServiceUser ? <>
Coral user ID: <Text style={styles.friendCoralIdValue}>{friend.id}</Text>
</> : 'Never used Nintendo Switch Online app'}</Text>
<Text style={[styles.friendCreatedAt, theme.text]}>Friends since {new Date(friend.friendCreatedAt * 1000).toLocaleString('en-GB')}</Text>
{friend.presence.updatedAt ? <Text style={[styles.presenceUpdatedAt, theme.text]}>Presence updated at {new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB')}</Text> : null}
{!(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
friend.presence.logoutAt ? <Text style={[styles.presenceUpdatedAt, theme.text]}>Last online at {new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB')}</Text> : null}
<Text style={[styles.canSeeUserPresence, theme.text]}>This user {can_see_user_presence ? 'can' : 'can not'} see your presence.</Text>
</View>
<View style={styles.buttons}>
{discord_presence_active || friend.presence.updatedAt ? <View style={styles.discord}>
<CheckBox
value={discord_presence_active ?? false}
onValueChange={v => ipc.setDiscordPresenceSource(v ?
{na_id: user.user.id, friend_nsa_id: friend.nsaId} : null)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.discordCheckbox}
/>
<TouchableOpacity onPress={() => ipc.setDiscordPresenceSource(!discord_presence_active ?
{na_id: user.user.id, friend_nsa_id: friend.nsaId} : null)}>
<Text style={theme.text}>Share presence to Discord</Text>
</TouchableOpacity>
</View> : null}
<Button title="Close"
onPress={() => window.close()}
<View style={styles.buttons}>
{props.showDiscordPresenceSetup ? <View style={styles.discord}>
<CheckBox
value={props.discordPresenceActive ?? false}
onValueChange={v => props.setDiscordPresenceActive?.(!!v)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
primary autoFocus />
</View>
style={styles.discordCheckbox}
/>
<TouchableOpacity onPress={() => props.setDiscordPresenceActive?.(!props.discordPresenceActive)}>
<Text style={theme.text}>{t('discord_presence')}</Text>
</TouchableOpacity>
</View> : null}
<Button title={t('close')}
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
primary autoFocus />
</View>
</View>
</Root>;
</View>;
}
function FriendPresence(props: {
presence: Presence;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
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 game = 'name' in props.presence.game ? props.presence.game : null;
if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) {
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing {game?.name}</Text>;
if ((props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) && game) {
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>{t('presence_playing', {game: game.name})}</Text>;
}
return <View>
<Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Offline</Text>
{logout ? <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Last seen {since_logout}</Text> : null}
<Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>{t('presence_offline')}</Text>
{logout ? <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>{t('presence_last_seen', {since_logout})}</Text> : null}
</View>;
}
@ -140,6 +178,7 @@ function FriendPresenceGame(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('friend_window');
const openShop = useCallback(() => {
ipc.openExternalUrl(props.game.shopUri);
@ -154,12 +193,21 @@ function FriendPresenceGame(props: {
<View style={styles.gameDetail}>
<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]}>Played for {hrduration(props.game.totalPlayTime)}</Text>
<Text style={[styles.gameFirstPlayed, theme.text]}>First played {first_played?.toLocaleString('en-GB') ?? 'now'}</Text>
{titleid ? <Text style={[styles.gameTitleId, theme.text]}>Title ID: <Text style={styles.gameTitleIdValue}>{titleid}</Text></Text> : null}
<Text style={[styles.gameTotalPlayTime, theme.text]}>
{t('game_played_for', {duration: hrduration(props.game.totalPlayTime)})}
</Text>
<Text style={[styles.gameFirstPlayed, theme.text]}>
{first_played ? t('game_first_played', {
date: first_played,
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
}) : t('game_first_played_now')}
</Text>
{titleid ? <Text style={[styles.gameTitleId, theme.text]}>
{t('game_title_id')}: <Text style={styles.gameTitleIdValue}>{titleid}</Text>
</Text> : null}
<View style={styles.gameShopButton}>
<Button title="Nintendo eShop" onPress={openShop} color={'#' + accent_colour} />
<Button title={t('game_shop')} onPress={openShop} color={'#' + accent_colour} />
</View>
</View>
</View>;

View File

@ -1,11 +1,12 @@
import React, { useCallback, useMemo } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { Trans, useTranslation } from 'react-i18next';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener, User } from '../util.js';
import { Friend, PresencePermissions } from '../../../api/coral-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
import { Button } from '../components/index.js';
import { Button, NintendoSwitchUser, NintendoSwitchUsers } from '../components/index.js';
export default function SetupDiscordPresence(props: {
user: User;
@ -13,6 +14,7 @@ export default function SetupDiscordPresence(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'discord_section' });
const [source, discord_presence_source_state] = useDiscordPresenceSource();
@ -35,51 +37,46 @@ export default function SetupDiscordPresence(props: {
const content = !source && added_friends?.length ? <>
<Text style={[styles.text, theme.text]}>
Use one of these accounts to set up Discord Rich Presence for this user:{' '}
{added_friends.map((u, i) => <React.Fragment key={u.user.id}>
{i === 0 ? '' : ', '}
<Image source={{uri: u.nso!.nsoAccount.user.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{u.nso!.nsoAccount.user.name}
{u.nso!.nsoAccount.user.name !== u.user.nickname ? ' (' + u.user.nickname + ')' : ''}
</React.Fragment>)}.
<Trans i18nKey="main_window:discord_section.setup_with_existing_user">
<NintendoSwitchUsers users={added_friends.map(u => ({
user: u.nso!.nsoAccount.user, nickname: u.user.nickname,
}))} />
</Trans>
</Text>
<View style={styles.button}>
<Button title="Setup" onPress={() => ipc.showDiscordModal({
<Button title={t('setup')} onPress={() => ipc.showDiscordModal({
users: added_friends.map(u => u.user.id),
friend_nsa_id: props.user.nso!.nsoAccount.user.nsaId,
})} color={'#' + accent_colour} />
</View>
</> :!source && users ? <>
<Text style={[styles.text, theme.text]}>Add a Nintendo Switch Online account with this user as a friend to set up Discord Rich Presence.</Text>
<Text style={[styles.text, theme.text]}>{t('add_user')}</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && !source.friend_nsa_id ? <>
<Text style={[styles.text, theme.text]}>
This user's presence is being shared to Discord.
</Text>
<Text style={[styles.text, theme.text]}>{t('active_self')}</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && friend ? <>
<Text style={[styles.text, theme.text]}>
<Image source={{uri: friend.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{friend.name}'s presence is being shared to Discord using this account.
<Trans i18nKey="main_window:discord_section.active_friend">
<NintendoSwitchUser friend={friend} />
</Trans>
</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && source.friend_nsa_id ? <>
<Text style={[styles.text, theme.text]}>
An unknown user's presence is being shared to Discord using this account.
</Text>
<Text style={[styles.text, theme.text]}>{t('active_unknown')}</Text>
</> : source && 'na_id' in source && auth_user && source.friend_nsa_id && source.friend_nsa_id === props.user.nso?.nsoAccount.user.nsaId ? <>
<Text style={[styles.text, theme.text]}>
This user's presence is being shared to Discord using{' '}
<Image source={{uri: auth_user.nsoAccount.user.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{auth_user.nsoAccount.user.name}
{auth_user.nsoAccount.user.name !== auth_user.user.nickname ? ' (' + auth_user.user.nickname + ')' : ''}.
<Trans i18nKey="main_window:discord_section.active_via">
<NintendoSwitchUser user={auth_user.nsoAccount.user} nickname={auth_user.user.nickname} />
</Trans>
</Text>
</> : null;
return content ? <Section title="Discord Rich Presence">
return content ? <Section title={t('title')}>
<View style={styles.content}>
{content}
{source ? <View style={styles.button}>
<Button title="Disable" onPress={() => ipc.setDiscordPresenceSource(null)} color={'#' + accent_colour} />
<Button title={t('disable')}
onPress={() => ipc.setDiscordPresenceSource(null)} color={'#' + accent_colour} />
</View> : null}
</View>
</Section> : null;
@ -96,10 +93,6 @@ const styles = StyleSheet.create({
fontSize: 13,
opacity: 0.7,
},
discordNsoUserImage: {
borderRadius: 8,
textAlignVertical: -3,
},
button: {
marginTop: 10,

View File

@ -1,11 +1,13 @@
import React, { useCallback, useEffect } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Trans, useTranslation } from 'react-i18next';
import { User } from 'discord-rpc';
import ipc, { events } from '../ipc.js';
import { RequestState, useAsync, useEventListener } from '../util.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral } from '../../common/types.js';
import { DiscordPresence } from '../../../discord/types.js';
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
import { NintendoSwitchUser } from '../components/index.js';
export default function DiscordPresenceSource(props: {
source: DiscordPresenceSource | null;
@ -35,6 +37,8 @@ function renderDiscordPresenceSource(source: DiscordPresenceSource | null) {
function DiscordPresenceSourceCoral(props: {
source: DiscordPresenceSourceCoral;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const [token] = useAsync(useCallback(() =>
ipc.getNintendoAccountCoralToken(props.source.na_id), [ipc, props.source.na_id]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
@ -51,11 +55,11 @@ function DiscordPresenceSourceCoral(props: {
return <View style={styles.discordSource}>
{friend ? <Text style={styles.discordSourceText}>
Discord Rich Presence active:{' '}
<Image source={{uri: friend.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{friend.name}
<Trans i18nKey="main_window:sidebar.discord_active_friend">
<NintendoSwitchUser friend={friend} />
</Trans>
</Text> : <Text style={styles.discordSourceText}>
Discord Rich Presence active
{t('discord_active')}
</Text>}
</View>;
}
@ -63,17 +67,21 @@ function DiscordPresenceSourceCoral(props: {
function DiscordPresenceSourceUrl(props: {
source: DiscordPresenceSourceUrl;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
return <View style={styles.discordSource}>
<Text style={styles.discordSourceText} numberOfLines={3} ellipsizeMode="tail">
Discord Rich Presence active:{' '}
{t('discord_active')}:{' '}
<Text style={styles.discordSourceUrlValue}>{props.source.url}</Text>
</Text>
</View>;
}
function DiscordPresenceInactive() {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
return <View style={styles.discordSource}>
<Text style={styles.discordSourceText}>Discord Rich Presence not active</Text>
<Text style={styles.discordSourceText}>{t('discord_not_active')}</Text>
</View>;
}
@ -81,6 +89,8 @@ function DiscordPresence(props: {
presence: DiscordPresence;
user: User;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const large_image_url = props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
'https://cdn.discordapp.com/app-assets/' + props.presence.id + '/' +
props.presence.activity.largeImageKey + '.png' :
@ -90,7 +100,7 @@ function DiscordPresence(props: {
return <>
<View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">Playing</Text>
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">{t('discord_playing')}</Text>
</View>
<View style={styles.discordUser}>
@ -115,10 +125,6 @@ const styles = StyleSheet.create({
discordSourceText: {
color: TEXT_COLOUR_DARK,
},
discordNsoUserImage: {
borderRadius: 8,
textAlignVertical: -3,
},
discordSourceUrlValue: {
fontFamily: 'monospace',
fontSize: 12,

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc from '../ipc.js';
import { useAccentColour, useColourScheme, User } from '../util.js';
import { ActiveEvent } from '../../../api/coral-types.js';
@ -15,27 +16,29 @@ export default function Event(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'event_section' });
const event_members = props.event.members.filter(m => m.isPlaying).length;
const voip_members = props.event.members.filter(m => m.isJoinedVoip).length;
return <Section title="Voice chat" loading={props.loading} error={props.error}>
return <Section title={t('title')} loading={props.loading} error={props.error}>
<View style={styles.content}>
<Image source={{uri: props.event.imageUri, width: 100, height: 100}} style={styles.image} />
<View style={styles.detail}>
<Text style={[styles.eventName, theme.text]}>{props.event.name}</Text>
<Text style={[styles.eventMembers, theme.text]}>
{event_members} in game, {voip_members} in voice chat
{props.event.members.length > 1 ? ' of ' + props.event.members.length + ' members' : ''}
{props.event.members.length > 1 ?
t('members_with_total', {event: event_members, voip: voip_members, total: props.event.members.length}) :
t('members', {event: event_members, voip: voip_members})}
</Text>
<Text style={[styles.eventInstruction, theme.text]}>
Use the Nintendo Switch Online app on iOS or Android to {voip_members ? 'join' : 'start'} voice chat.
{t(voip_members ? 'app_join' : 'app_start')}
</Text>
{props.event.shareUri ? <View style={styles.shareButton}>
<Button title="Share" onPress={() => ipc.share({urls: [props.event.shareUri]})}
<Button title={t('share')} onPress={() => ipc.share({urls: [props.event.shareUri]})}
color={'#' + accent_colour} />
</View> : null}
</View>

View File

@ -1,5 +1,6 @@
import React, { useCallback } from 'react';
import { Image, ImageStyle, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { 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';
@ -15,6 +16,7 @@ export default function Friends(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'friends_section' });
const showAddFriendModal = useCallback(() => {
ipc.showAddFriendModal({user: props.user.user.id});
@ -34,18 +36,18 @@ export default function Friends(props: {
onContextMenu={onFriendCodeContextMenu}
>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
return <Section title="Friends" loading={props.loading} error={props.error} headerButtons={header_buttons}>
return <Section title={t('title')} loading={props.loading} error={props.error} headerButtons={header_buttons}>
{props.friends.length ? <ScrollView horizontal>
<View style={styles.content}>
{props.friends.map(f => <Friend key={f.nsaId} friend={f} user={props.user} />)}
</View>
</ScrollView> : <View style={styles.noFriends}>
<Text style={[styles.noFriendsText, theme.text]}>Add friends using a Nintendo Switch console.</Text>
<Text style={[styles.noFriendsText, styles.noFriendsFriendCodeText, theme.text]}>Your friend code: {fc}</Text>
<Text style={[styles.noFriendsText, theme.text]}>{t('no_friends')}</Text>
<Text style={[styles.noFriendsText, styles.noFriendsFriendCodeText, theme.text]}>{t('friend_code')}: {fc}</Text>
</View>}
{props.friends.length ? <View style={styles.footer}>
<Text style={[styles.friendCode, theme.text]}>Your friend code: {fc}</Text>
<Text style={[styles.friendCode, theme.text]}>{t('friend_code')}: {fc}</Text>
</View> : null}
</Section>;
}
@ -94,15 +96,16 @@ function FriendPresence(props: {
presence: Presence;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
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);
if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) {
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing</Text>;
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>{t('presence_playing')}</Text>;
}
return <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>{logout ? since_logout : 'Offline'}</Text>;
return <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>{logout ? since_logout : t('presence_offline')}</Text>;
}
const styles = StyleSheet.create({

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { CheckBox } from 'react-native-web';
import { Translation, useTranslation } from 'react-i18next';
import ipc, { events } from '../ipc.js';
import { AccentColourContext, getAccounts, Root, useAsync, useEventListener } from '../util.js';
import Sidebar from './sidebar.js';
@ -35,6 +36,7 @@ export default function App(props: AppProps) {
titleUser={selectedUser}
vibrancy={props.vibrancy}
style={[styles.app, !props.vibrancy ? theme.appNoVibrancy : null]}
i18nNamespace="main_window"
>
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId}
insetTitleBarControls={props.insetTitleBarControls}
@ -49,7 +51,9 @@ export default function App(props: AppProps) {
/>
)} />
<TouchableOpacity onPress={() => setAutoRefresh(auto_refresh ? undefined : 30 * 1000)}>
<Text style={theme.text}>Enable auto refresh</Text>
<Translation keyPrefix="main_window:sidebar">{
t => <Text style={theme.text}>{t('enable_auto_refresh')}</Text>
}</Translation>
</TouchableOpacity>
</View>
</Sidebar>

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc, { events } from '../ipc.js';
import { RequestState, useAccentColour, useAsync, useColourScheme, useEventListener, User } from '../util.js';
import Friends from './friends.js';
@ -9,7 +10,6 @@ import Section from './section.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import SetupDiscordPresence from './discord-setup.js';
import { Button } from '../components/index.js';
import { hrlist } from '../../../util/misc.js';
export default function Main(props: {
user: User;
@ -17,6 +17,7 @@ export default function Main(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'main_section' });
const [announcements, announcements_error, announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getCoralAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
@ -58,18 +59,17 @@ export default function Main(props: {
if (friends_error || webservices_error || active_event_error) {
const errors = [];
if (friends_error) errors.push('friends');
if (webservices_error) errors.push('game-specific services');
if (active_event_error) errors.push('voice chat');
const errors_text = hrlist(errors);
if (friends_error) errors.push(t('error.message_friends'));
if (webservices_error) errors.push(t('error.message_webservices'));
if (active_event_error) errors.push(t('error.message_event'));
return <View style={styles.error}>
<Text style={[styles.errorHeader, theme.text]}>Error loading data</Text>
<Text style={[styles.errorMessage, theme.text]}>An error occured while loading {errors_text} data.</Text>
<Text style={[styles.errorHeader, theme.text]}>{t('error.title')}</Text>
<Text style={[styles.errorMessage, theme.text]}>{t('error.message', {errors})}</Text>
<View style={styles.errorActions}>
<Button title="Retry" onPress={refresh} color={'#' + accent_colour} primary />
<Button title={t('error.retry')} onPress={refresh} color={'#' + accent_colour} primary />
<TouchableOpacity onPress={showErrorDetails} style={styles.errorViewDetailsTouchable}>
<Text style={theme.text}>View details</Text>
<Text style={theme.text}>{t('error.view_details')}</Text>
</TouchableOpacity>
</View>
</View>;
@ -92,14 +92,15 @@ export default function Main(props: {
function MoonOnlyUser() {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'main_section.moon_only_user' });
return <Section title="Nintendo Switch Online">
return <Section title={t('title')}>
<View style={styles.moonOnlyUser}>
<Text style={[styles.moonOnlyUserText, theme.text]}>This user is signed in to the Nintendo Switch Parental Controls app, but not the Nintendo Switch Online app.</Text>
<Text style={[styles.moonOnlyUserText, theme.text]}>Login to the Nintendo Switch Online app to view details here, or use the nxapi command to access Parental Controls data.</Text>
<Text style={[styles.moonOnlyUserText, theme.text]}>{t('desc_1')}</Text>
<Text style={[styles.moonOnlyUserText, theme.text]}>{t('desc_2')}</Text>
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addCoralAccount()} color={'#' + accent_colour} primary />
<Button title={t('login')} onPress={() => ipc.addCoralAccount()} color={'#' + accent_colour} primary />
</View>
</View>
</Section>;

View File

@ -1,5 +1,6 @@
import React, { useCallback } from 'react';
import { Image, ImageURISource, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc from '../ipc.js';
import { RequestState, useActiveDiscordPresence, useActiveDiscordUser, useColourScheme, useDiscordPresenceSource, User } from '../util.js';
import DiscordPresenceSource from './discord.js';
@ -13,6 +14,7 @@ export default function Sidebar(props: {
children?: React.ReactNode;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const presence = useActiveDiscordPresence();
@ -40,13 +42,13 @@ export default function Sidebar(props: {
</View> : null}
{props.users ? <TouchableOpacity onPress={() => ipc.showAddUserMenu()} style={styles.addUser}>
<Text style={theme.text}>Add user</Text>
<Text style={theme.text}>{t('add_user')}</Text>
</TouchableOpacity> : null}
{discord_presence_source_state === RequestState.LOADED && !discord_presence_source ? <TouchableOpacity
onPress={() => ipc.showDiscordModal()} style={styles.discordSetup}
>
<Text style={theme.text}>Set up Discord Rich Presence</Text>
<Text style={theme.text}>{t('discord_setup')}</Text>
</TouchableOpacity> : null}
{props.children}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc from '../ipc.js';
import { useAccentColour, useEventListener } from '../util.js';
import type { UpdateCacheData } from '../../../common/update.js';
@ -8,22 +9,23 @@ import { Button } from '../components/index.js';
export default function Update() {
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'update' });
const [update, setUpdateData] = useState<UpdateCacheData | null>(null);
useEffect(() => (ipc.getUpdateData().then(setUpdateData), undefined), [ipc]);
useEventListener(ipc.events, 'nxapi:update:latest', setUpdateData, [ipc.events]);
return update && 'update_available' in update && update.update_available ? <View style={styles.container}>
<Text style={styles.updateText}>Update available: {update.latest.name}</Text>
<Text style={styles.updateText}>{t('update_available', {name: update.latest.name})}</Text>
<View style={styles.updateButton}>
<Button title="Download"
<Button title={t('download')}
onPress={() => ipc.openExternalUrl(update.latest.html_url)}
color={'#' + accent_colour} />
</View>
</View> : update && 'error_message' in update ? <View style={styles.container}>
<Text style={styles.updateText}>Error checking for updates: {update.error_message}</Text>
<Text style={styles.updateText}>{t('error', {message: update.error_message})}</Text>
<View style={styles.updateButton}>
<Button title="Try again"
<Button title={t('retry')}
onPress={() => (setUpdateData(null), ipc.checkUpdates())}
color={'#' + accent_colour} />
</View>

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc from '../ipc.js';
import { useColourScheme, User } from '../util.js';
import { WebService } from '../../../api/coral-types.js';
@ -12,9 +13,11 @@ export default function WebServices(props: {
loading?: boolean;
error?: Error;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'webservices_section' });
if (!props.webservices.length) return null;
return <Section title="Game-specific services" loading={props.loading} error={props.error}>
return <Section title={t('title')} loading={props.loading} error={props.error}>
<ScrollView horizontal>
<View style={styles.content}>
{props.webservices.map(g => <WebService key={g.id} webservice={g} token={props.user.nsotoken} />)}

View File

@ -1,20 +1,29 @@
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { StyleSheet, Text, TextInput, TouchableHighlight, TouchableOpacity, useColorScheme, View } from 'react-native';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { CheckBox, Picker } from 'react-native-web';
import { DiscordPresencePlayTime } from '../../../discord/types.js';
import { Button } from '../components/index.js';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, Root, useAsync, useDiscordPresenceSource, useEventListener } from '../util.js';
import { getAccounts, RequestState, Root, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener } from '../util.js';
export interface PreferencesProps {}
export default function Preferences(props: PreferencesProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
export default function PreferencesWindow(props: PreferencesProps) {
return <Root
title={i18n => i18n.t('preferences_window:title')} scrollable autoresize
i18nNamespace="preferences_window"
>
<Preferences />
</Root>;
}
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
function _Preferences(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const { t, i18n, ready } = useTranslation('preferences_window');
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
@ -94,12 +103,13 @@ export default function Preferences(props: PreferencesProps) {
if (!users ||
!login_item ||
!has_ever_loaded_discord_options ||
discord_presence_source_state !== RequestState.LOADED
discord_presence_source_state !== RequestState.LOADED ||
!ready
) {
return null;
}
const discord_user_picker = [<Picker.Item key="*" label="First discovered" value="*" />];
const discord_user_picker = [<Picker.Item key="*" label={t('discord.user_any')!} value="*" />];
if (discord_options?.user && !discord_users?.find(u => u.id === discord_options.user)) {
discord_user_picker.push(<Picker.Item key={discord_options?.user} label={discord_options.user}
@ -110,175 +120,170 @@ export default function Preferences(props: PreferencesProps) {
value={user.id} />);
}
return <Root title="Preferences" scrollable autoresize>
<View style={styles.main}>
{/* <Text style={theme.text}>Preferences</Text> */}
return <View style={styles.main}>
{/* <Text style={theme.text}>Preferences</Text> */}
{login_item.supported || login_item.startup_enabled ? <View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Startup</Text>
{login_item.supported || login_item.startup_enabled ? <View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>{t('startup.heading')}</Text>
</View>
<View style={styles.sectionRight}>
<View style={styles.checkboxContainer}>
<CheckBox
value={login_item.startup_enabled}
onValueChange={setOpenAtLogin}
disabled={!login_item.supported}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity disabled={!login_item.supported} style={styles.checkboxLabel} onPress={() => setOpenAtLogin(!login_item.startup_enabled)}>
<Text style={[styles.checkboxLabelText, theme.text]}>{t('startup.login')}</Text>
</TouchableOpacity>
</View>
<View style={styles.sectionRight}>
{/* <Text style={theme.text}>Launch at startup menu here</Text>
<Text style={theme.text}>{JSON.stringify(login_item, null, 4)}</Text> */}
<View
style={[styles.checkboxContainer, !login_item.startup_enabled ? styles.disabled : null]}
>
<CheckBox
value={login_item.startup_hidden}
onValueChange={setOpenAsHidden}
disabled={!login_item.startup_enabled}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity disabled={!login_item.startup_enabled} style={styles.checkboxLabel}
onPress={() => setOpenAsHidden(!login_item.startup_hidden)}
>
<Text style={[styles.checkboxLabelText, theme.text]}>{t('startup.background')}</Text>
</TouchableOpacity>
</View>
</View>
</View> : null}
{/* <View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Sleep</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>Prevent sleep menu here</Text>
</View>
</View> */}
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>{t('discord.heading')}</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>{t(discord_presence_source ? 'discord.enabled' : 'discord.disabled')}</Text>
<View style={styles.button}>
<Button title={t('discord.setup')}
onPress={() => ipc.showDiscordModal({show_preferences_button: false})}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<Text style={[styles.header, theme.text]}>{t('discord.user')}</Text>
<Picker<string> selectedValue={discord_options?.user ?? '*'} onValueChange={setDiscordUser}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING &&
discord_users_state !== RequestState.LOADING}
>{...discord_user_picker}</Picker>
<Text style={[styles.header, theme.text]}>{t('discord.friend_code')}</Text>
<Text style={[styles.help, theme.text]}>{t('discord.friend_code_help')}</Text>
{is_discord_friend_code_self ? <View style={styles.friendCodeCheckbox}>
<View style={styles.checkboxContainer}>
<CheckBox
value={login_item.startup_enabled}
onValueChange={setOpenAtLogin}
disabled={!login_item.supported}
value={!!discord_options?.friend_code}
onValueChange={v => setDiscordFriendCode(v ? discord_friend_code_self : undefined)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity disabled={!login_item.supported} style={styles.checkboxLabel} onPress={() => setOpenAtLogin(!login_item.startup_enabled)}>
<Text style={[styles.checkboxLabelText, theme.text]}>Open at login</Text>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordFriendCode(discord_options?.friend_code ? undefined : discord_friend_code_self)}>
<Text style={theme.text}>{t('discord.friend_code_self')}</Text>
</TouchableOpacity>
</View>
<View
style={[styles.checkboxContainer, !login_item.startup_enabled ? styles.disabled : null]}
>
<CheckBox
value={login_item.startup_hidden}
onValueChange={setOpenAsHidden}
disabled={!login_item.startup_enabled}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity disabled={!login_item.startup_enabled} style={styles.checkboxLabel}
onPress={() => setOpenAsHidden(!login_item.startup_hidden)}
>
<Text style={[styles.checkboxLabelText, theme.text]}>Open in background</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.textLinkTouchable} onPress={() => setIsDiscordFriendCodeSelf(false)}>
<Text style={[styles.textLink, theme.text, {color: '#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}]}>
{t('discord.friend_code_custom')}
</Text>
</TouchableOpacity>
</View> : <View style={styles.friendCodeInput}>
<TextInput value={discord_friend_code} onChangeText={setDiscordFriendCode}
placeholder="0000-0000-0000"
style={[styles.textInput, theme.textInput]} />
</View>}
<View style={[styles.checkboxContainer, styles.checkboxContainerMargin]}>
<CheckBox
value={discord_options?.show_console_online ?? false}
onValueChange={setDiscordShowConsoleOnline}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordShowConsoleOnline(!discord_options?.show_console_online)}>
<Text style={[styles.checkboxLabelText, theme.text]}>{t('discord.inactive_presence')}</Text>
</TouchableOpacity>
</View>
</View> : null}
<Text style={[styles.help, theme.text]}>{t('discord.inactive_presence_help')}</Text>
{/* <View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Sleep</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>Prevent sleep menu here</Text>
</View>
</View> */}
<Text style={[styles.header, theme.text]}>{t('discord.play_time')}</Text>
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Discord Rich Presence</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>Discord Rich Presence is {discord_presence_source ? 'en' : 'dis'}abled.</Text>
<View style={styles.button}>
<Button title="Discord Rich Presence setup"
onPress={() => ipc.showDiscordModal({show_preferences_button: false})}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<Text style={[styles.header, theme.text]}>Discord user</Text>
<Picker<string> selectedValue={discord_options?.user ?? '*'} onValueChange={setDiscordUser}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING &&
discord_users_state !== RequestState.LOADING}
>{...discord_user_picker}</Picker>
<Text style={[styles.header, theme.text]}>Friend code</Text>
<Text style={[styles.help, theme.text]}>Adding your friend code will also show your Nintendo Switch user icon in Discord.</Text>
{is_discord_friend_code_self ? <View style={styles.friendCodeCheckbox}>
<View style={styles.checkboxContainer}>
<CheckBox
value={!!discord_options?.friend_code}
onValueChange={v => setDiscordFriendCode(v ? discord_friend_code_self : undefined)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordFriendCode(discord_options?.friend_code ? undefined : discord_friend_code_self)}>
<Text style={theme.text}>Share my friend code</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.textLinkTouchable} onPress={() => setIsDiscordFriendCodeSelf(false)}>
<Text style={[styles.textLink, theme.text, {color: '#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}]}>
Set custom friend code
</Text>
</TouchableOpacity>
</View> : <View style={styles.friendCodeInput}>
<TextInput value={discord_friend_code} onChangeText={setDiscordFriendCode}
placeholder="0000-0000-0000"
style={[styles.textInput, theme.textInput]} />
</View>}
{/* <View style={styles.header} /> */}
<View style={[styles.checkboxContainer, styles.checkboxContainerMargin]}>
<CheckBox
value={discord_options?.show_console_online ?? false}
onValueChange={setDiscordShowConsoleOnline}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordShowConsoleOnline(!discord_options?.show_console_online)}>
<Text style={[styles.checkboxLabelText, theme.text]}>Show inactive presence</Text>
</TouchableOpacity>
</View>
<Text style={[styles.help, theme.text]}>Shows "Not playing" when a console linked to your account is online, but you are not selected in a game.</Text>
<Text style={[styles.header, theme.text]}>Play time</Text>
<Picker<string>
selectedValue={'' + (discord_options?.show_play_time ??
DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE)}
onValueChange={v => setDiscordShowPlayTime(parseInt(v))}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING}
>
<Picker.Item key={DiscordPresencePlayTime.HIDDEN} value={DiscordPresencePlayTime.HIDDEN}
label="Never show play time" />
<Picker.Item key={DiscordPresencePlayTime.NINTENDO} value={DiscordPresencePlayTime.NINTENDO}
label="Show play time as it appears on a Nintendo Switch console" />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME}
label="Show approximate play time (nearest 5 hours)" />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE}
label="Show approximate play time (nearest 5 hours) with first played date" />
<Picker.Item key={DiscordPresencePlayTime.HOUR_PLAY_TIME} value={DiscordPresencePlayTime.HOUR_PLAY_TIME}
label="Show approximate play time (nearest hour)" />
<Picker.Item key={DiscordPresencePlayTime.HOUR_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.HOUR_PLAY_TIME_SINCE}
label="Show approximate play time (nearest hour) with first played date" />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME}
label="Show exact play time" />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE}
label="Show exact play time with first played date" />
</Picker>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>SplatNet 3</Text>
</View>
<View style={styles.sectionRight}>
<View style={[styles.checkboxContainer]}>
<CheckBox
value={discord_options?.monitors?.enable_splatnet3_monitoring ?? false}
onValueChange={setDiscordEnableSplatNet3Monitor}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordEnableSplatNet3Monitor(!discord_options?.monitors?.enable_splatnet3_monitoring)}>
<Text style={[styles.checkboxLabelText, theme.text]}>Enable enhanced Discord Rich Presence for Splatoon 3</Text>
</TouchableOpacity>
</View>
<Text style={[styles.help, theme.text]}>Uses SplatNet 3 to retrieve additional presence information while playing Splatoon 3. You must be using a secondary Nintendo Account that is friends with your main account to fetch your presence, and the secondary account must be able to access SplatNet 3.</Text>
<Text style={[styles.help, theme.text]}>When using a presence URL that returns Splatoon 3 data additional presence information will be shown regardless of this setting.</Text>
</View>
<Picker<string>
selectedValue={'' + (discord_options?.show_play_time ??
DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE)}
onValueChange={v => setDiscordShowPlayTime(parseInt(v))}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING}
>
<Picker.Item key={DiscordPresencePlayTime.HIDDEN} value={DiscordPresencePlayTime.HIDDEN}
label={t('discord.play_time_hidden')!} />
<Picker.Item key={DiscordPresencePlayTime.NINTENDO} value={DiscordPresencePlayTime.NINTENDO}
label={t('discord.play_time_nintendo')!} />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME}
label={t('discord.play_time_approximate_play_time')!} />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE}
label={t('discord.play_time_approximate_play_time_since')!} />
<Picker.Item key={DiscordPresencePlayTime.HOUR_PLAY_TIME} value={DiscordPresencePlayTime.HOUR_PLAY_TIME}
label={t('discord.play_time_hour_play_time')!} />
<Picker.Item key={DiscordPresencePlayTime.HOUR_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.HOUR_PLAY_TIME_SINCE}
label={t('discord.play_time_hour_play_time_since')!} />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME}
label={t('discord.play_time_detailed_play_time')!} />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE}
label={t('discord.play_time_detailed_play_time_since')!} />
</Picker>
</View>
</View>
</Root>;
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>{t('splatnet3.heading')}</Text>
</View>
<View style={styles.sectionRight}>
<View style={[styles.checkboxContainer]}>
<CheckBox
value={discord_options?.monitors?.enable_splatnet3_monitoring ?? false}
onValueChange={setDiscordEnableSplatNet3Monitor}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordEnableSplatNet3Monitor(!discord_options?.monitors?.enable_splatnet3_monitoring)}>
<Text style={[styles.checkboxLabelText, theme.text]}>{t('splatnet3.discord')}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.help, theme.text]}>{t('splatnet3.discord_help_1')}</Text>
<Text style={[styles.help, theme.text]}>{t('splatnet3.discord_help_2')}</Text>
</View>
</View>
</View>;
}
const Preferences = React.memo(_Preferences);
const styles = StyleSheet.create({
loading: {
flex: 1,

View File

@ -1,6 +1,9 @@
import { EventEmitter } from 'node:events';
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { ColorSchemeName, LayoutChangeEvent, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native';
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 { 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';
@ -9,16 +12,18 @@ import { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js';
import createI18n from '../i18n/index.js';
export const WindowFocusedContext = React.createContext(false);
export function Root(props: React.PropsWithChildren<{
title?: string;
title?: string | ((i18n: i18n) => string);
titleUser?: User | SavedToken;
style?: StyleProp<ViewStyle>;
scrollable?: boolean;
autoresize?: boolean;
vibrancy?: boolean;
i18nNamespace?: string | string[];
}>) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
@ -38,22 +43,45 @@ export function Root(props: React.PropsWithChildren<{
};
}, []);
useEffect(() => {
const user_na = props.titleUser?.user;
const user_nso = (props.titleUser && 'nso' in props.titleUser ? props.titleUser.nso : props.titleUser)?.nsoAccount.user;
const user_prefix =
user_na ? '[' + user_na.nickname +
(user_nso && user_nso.name !== user_na.nickname ? '/' + user_nso.name : '') +
'] ' : '';
const [i18n, i18n_error] = useAsync(useCallback(async () => {
const i18n = createI18n();
document.title = user_prefix + (props.title ?? 'Nintendo Switch Online');
}, [props.title, props.titleUser]);
// @ts-expect-error
window.i18n = i18n;
await i18n
.use(LanguageDetector as unknown as typeof import('i18next-browser-languagedetector').default)
.use(initReactI18next)
.init();
await i18n.loadNamespaces('app');
if (props.i18nNamespace) await i18n.loadNamespaces(props.i18nNamespace);
return i18n;
}, []));
if (i18n_error) throw i18n_error;
useEventListener(events, 'update-language', language => i18n?.changeLanguage(language), [i18n]);
// Force rerender when language changes so the window title is updated
const [language, setCurrentLanguage] = useState();
useEffect(() => {
i18n?.on('languageChanged', setCurrentLanguage);
return () => i18n?.off('languageChanged', setCurrentLanguage);
}, [i18n]);
useAsync(useCallback(async () => i18n?.loadNamespaces('app'), [i18n, language]));
useAsync(useCallback(async () => props.i18nNamespace && i18n?.loadNamespaces(props.i18nNamespace),
[i18n, props.i18nNamespace, language]));
const [preventingFocus, setPreventFocus] = useState(true);
const unlockFocus = useCallback(() => setPreventFocus(false), []);
useLayoutEffect(() => setPreventFocus(props.autoresize ?? true), [props.autoresize]);
const onLayout = useCallback(async (event: LayoutChangeEvent) => {
if (!event.nativeEvent.layout.height) return;
await ipc.setWindowHeight(event.nativeEvent.layout.height);
setPreventFocus(false);
}, []);
@ -62,6 +90,17 @@ export function Root(props: React.PropsWithChildren<{
document.documentElement.style.overflowY = props.scrollable ? 'auto' : 'hidden';
}, [props.scrollable]);
const title_user_prefix = useMemo(() => {
const user_na = props.titleUser?.user;
const user_nso = (props.titleUser && 'nso' in props.titleUser ? props.titleUser.nso : props.titleUser)?.nsoAccount.user;
return user_na ? '[' + user_na.nickname +
(user_nso && user_nso.name !== user_na.nickname ? '/' + user_nso.name : '') +
'] ' : '';
}, [props.titleUser]);
if (!i18n) return null;
const content = <View style={[
props.scrollable ? styles.appScrollable : styles.app,
!props.vibrancy ? theme.appNoVibrancy : null,
@ -83,12 +122,28 @@ export function Root(props: React.PropsWithChildren<{
return <ColourSchemeContext.Provider value={colour_scheme}>
<AccentColourContext.Provider value={accent_colour ?? DEFAULT_ACCENT_COLOUR}>
<WindowFocusedContext.Provider value={window_focused}>
{content}
<I18nextProvider i18n={i18n}>
<WindowTitle title={title_user_prefix + (
typeof props.title === 'function' ? props.title.call(null, i18n) :
props.title ?? i18n.t('app:default_title'))} />
{content}
</I18nextProvider>
</WindowFocusedContext.Provider>
</AccentColourContext.Provider>
</ColourSchemeContext.Provider>;
}
function WindowTitle(props: {
title: string;
}) {
useEffect(() => {
document.title = props.title;
}, [props.title]);
return null;
}
const styles = StyleSheet.create({
app: {
height: Platform.OS === 'web' ? '100vh' : '100%',

56
src/app/i18n/index.ts Normal file
View File

@ -0,0 +1,56 @@
import createDebug from 'debug';
import { BackendModule, CallbackError, createInstance, ReadCallback } from 'i18next';
const debug = createDebug('app:i18n');
export const languages = {
'en-GB': [() => import('./locale/en-gb.js'), 'English'] as const,
};
type Namespace = keyof typeof import('./locale/en-gb.js');
export default function createI18n() {
const i18n = createInstance({
fallbackLng: 'en-GB',
debug: true,
supportedLngs: Object.keys(languages),
load: 'currentOnly',
returnNull: false,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
react: {
useSuspense: false,
},
});
i18n.use(LanguageBackend);
return i18n;
}
const LanguageBackend: BackendModule = {
type: 'backend',
read: (
language: keyof typeof languages,
namespace: Namespace,
callback: ReadCallback,
) => {
debug('Loading %s translations for %s', namespace, language);
importLocale(language).then(resources => {
callback(null, resources[namespace as keyof typeof resources]);
}, (error: CallbackError) => {
callback(error, null);
});
},
init: null as any,
};
async function importLocale(language: keyof typeof languages) {
if (!(language in languages)) throw new Error('Unknown language ' + language);
return languages[language][0]();
}

View File

@ -0,0 +1,307 @@
import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js';
export const app = {
default_title: 'Nintendo Switch Online',
licence: LICENCE_NOTICE,
credits: CREDITS_NOTICE,
};
export const app_menu = {
preferences: 'Preferences',
view: 'View',
learn_more: 'Learn More',
learn_more_github: 'Learn More (GitHub)',
search_issues: 'Search Issues',
refresh: 'Refresh',
};
export const menu_app = {
coral_heading: 'Nintendo Switch Online',
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_presence_enable: 'Enable Discord Presence',
user_notifications_enable: 'Enable notifications for this user\'s presence',
friend_notifications_enable: 'Enable notifications for friends of this user\'s presence',
refresh: 'Update now',
add_friend: 'Add friend',
web_services: 'Web services',
moon_heading: 'Nintendo Switch Parental Controls',
add_account: 'Add account',
show_main_window: 'Show main window',
preferences: 'Preferences',
quit: 'Quit',
};
export const menus = {
add_account: {
add_account_coral: 'Add Nintendo Switch Online account',
add_account_moon: 'Add Nintendo Switch Parental Controls account',
},
friend_code: {
share: 'Share',
copy: 'Copy',
friend_code_regenerable: 'Regenerate using a Nintendo Switch console',
friend_code_regenerable_at: 'Can be regenerated at {{date, datetime}}',
},
user: {
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_disable: 'Disable Discord presence',
discord_enabled_for: 'Discord presence enabled for {{name}}',
discord_enabled_via: 'Discord presence enabled using {{name}}',
discord_enable: 'Enable Discord presence for this user...',
friend_notifications_enable: 'Enable friend notifications',
refresh: 'Update now',
add_friend: 'Add friend',
remove_help: 'Use the nxapi command to remove this user',
},
friend: {
presence_online: 'Online',
game_first_played: 'First played: {{date, datetime}}',
game_play_time: 'Play time: {{time, datetime}}',
presence_inactive: 'Offline (console online)',
presence_offline: 'Offline',
presence_updated: 'Updated: {{date, datetime}}',
presence_logout_time: 'Logout time: {{date, datetime}}',
discord_presence_enable: 'Enable Discord Presence',
},
};
export const notifications = {
playing: 'Playing {{name}}',
offline: 'Offline',
};
export const handle_uri = {
friend_code_select: 'Select a user to add friends',
web_service_select: 'Select a user to open this web service',
web_service_invalid_title: 'Invalid web service',
web_service_invalid_detail: 'The URL did not reference an existing web service.',
cancel: 'Cancel',
};
export const main_window = {
sidebar: {
discord_active: 'Discord Rich Presence active',
discord_active_friend: 'Discord Rich Presence active: <0></0>',
discord_not_active: 'Discord Rich Presence not active',
discord_playing: 'Playing',
add_user: 'Add user',
discord_setup: 'Set up Discord Rich Presence',
enable_auto_refresh: 'Enable auto refresh',
},
update: {
update_available: 'Update available: {{name}}',
download: 'Download',
error: 'Error checking for updates: {{message}}',
retry: 'Try again',
},
main_section: {
error: {
title: 'Error loading data',
message: 'An error occured while loading {{errors, list}} data.',
message_friends: 'friends',
message_webservices: 'game-specific services',
message_event: 'voice chat',
retry: 'Retry',
view_details: 'View details',
},
moon_only_user: {
title: 'Nintendo Switch Online',
desc_1: 'This user is signed in to the Nintendo Switch Parental Controls app, but not the Nintendo Switch Online app.',
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',
},
},
discord_section: {
title: 'Discord Rich Presence',
setup_with_existing_user: 'Use one of these accounts to set up Discord Rich Presence for this user: <0></0>.',
add_user: 'Add a Nintendo Switch Online account with this user as a friend to set up Discord Rich Presence.',
active_self: 'This user\'s presence is being shared to Discord.',
active_friend: '<0></0>\'s presence is being shared to Discord using this account.',
active_unknown: 'An unknown user\'s presence is being shared to Discord using this account.',
active_via: 'This user\'s presence is being shared to Discord using <0></0>.',
setup: 'Setup',
disable: 'Disable',
},
friends_section: {
title: 'Friends',
no_friends: 'Add friends using a Nintendo Switch console.',
friend_code: 'Your friend code',
presence_playing: 'Playing',
presence_offline: 'Offline',
},
webservices_section: {
title: 'Game-specific services',
},
event_section: {
title: 'Voice chat',
members: '{{event}} in game, {{voip}} in voice chat',
members_with_total: '{{event}} in game, {{voip}} in voice chat of {{total}} members',
app_start: 'Use the Nintendo Switch Online app on iOS or Android to start voice chat.',
app_join: 'Use the Nintendo Switch Online app on iOS or Android to join voice chat.',
share: 'Share',
},
};
export const preferences_window = {
title: 'Preferences',
startup: {
heading: 'Startup',
login: 'Open at login',
background: 'Open in background',
},
sleep: {
heading: 'Sleep',
},
discord: {
heading: 'Discord Rich Presence',
enabled: 'Discord Rich Presence is enabled.',
disabled: 'Discord Rich Presence is disabled.',
setup: 'Discord Rich Presence setup',
user: 'Discord user',
user_any: 'First discovered',
friend_code: 'Friend code',
friend_code_help: 'Adding your friend code will also show your Nintendo Switch user icon in Discord.',
friend_code_self: 'Share my friend code',
friend_code_custom: 'Set custom friend code',
inactive_presence: 'Show inactive presence',
inactive_presence_help: 'Shows "Not playing" when a console linked to your account is online, but you are not selected in a game.',
play_time: 'Play time',
play_time_hidden: 'Never show play time',
play_time_nintendo: 'Show play time as it appears on a Nintendo Switch console',
play_time_approximate_play_time: 'Show approximate play time (nearest 5 hours)',
play_time_approximate_play_time_since: 'Show approximate play time (nearest 5 hours) with first played date',
play_time_hour_play_time: 'Show approximate play time (nearest hour)',
play_time_hour_play_time_since: 'Show approximate play time (nearest hour) with first played date',
play_time_detailed_play_time: 'Show exact play time',
play_time_detailed_play_time_since: 'Show exact play time with first played date',
},
splatnet3: {
heading: 'SplatNet 3',
discord: 'Enable enhanced Discord Rich Presence for Splatoon 3',
discord_help_1: 'Uses SplatNet 3 to retrieve additional presence information while playing Splatoon 3. You must be using a secondary Nintendo Account that is friends with your main account to fetch your presence, and the secondary account must be able to access SplatNet 3.',
discord_help_2: 'When using a presence URL that returns Splatoon 3 data additional presence information will be shown regardless of this setting.',
},
};
export const friend_window = {
no_presence: 'You don\'t have access to this user\'s presence, or they have never been online.',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Never used the Nintendo Switch Online app',
friends_since: 'Friends since {{date, datetime}}',
presence_updated_at: 'Presence updated at {{date, datetime}}',
presence_logout_at: 'Last online at {{date, datetime}}',
presence_sharing: 'This user can see your presence.',
presence_not_sharing: 'This user can not see your presence.',
discord_presence: 'Share presence to Discord',
close: 'Close',
presence_playing: 'Playing {{game}}',
presence_offline: 'Offline',
presence_last_seen: 'Last seen {{since_logout}}',
game_played_for: 'Played for {{duration}}',
game_first_played: 'First played {{date, datetime}}',
game_first_played_now: 'First played now',
game_title_id: 'Title ID',
game_shop: 'Nintendo eShop',
};
export const addfriend_window = {
title: 'Add friend',
help: 'Type or paste a friend code or friend code URL to send a friend request.',
lookup_error: 'Error looking up friend code: {{message}}',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Never used the Nintendo Switch Online app',
send_added: 'You are now friends with this user.',
send_sent: 'Friend request sent. {{user}} can accept your friend request using a Nintendo Switch console, or by sending you a friend request using the Nintendo Switch Online app or nxapi.',
send_sending: 'Sending friend request...',
send_error: 'Error sending friend request: {{message}}',
already_friends: 'You are already friends with this user.',
close: 'Close',
send: 'Send friend request',
};
export const discordsetup_window = {
title: 'Discord Rich Presence setup',
mode_heading: '1. Select mode',
mode_coral_friend: 'Select a user that is friends with the user you want to share',
mode_url: 'Enter a URL that returns the presence data you want to share',
mode_none: 'Disable',
coral_user_heading: '2. Select user',
coral_user_help: 'This user must be friends with the user you want to share.',
coral_friend_heading: '3. Select friend',
coral_friend_help: 'This is the user you want to share.',
url_heading: '2. Enter presence URL',
url_help: 'This must be a HTTPS URL that returns a JSON object with either a user, friend or presence key. This is intended to be used with nxapi\'s znc API proxy.',
preferences_heading: 'Configure additional options for Discord Rich Presence',
preferences: 'Preferences',
cancel: 'Cancel',
save: 'Save',
};
export const addaccountmanual_window = {
title: 'Add account',
authorise_heading: '1. Login to your Nintendo Account',
authorise_help: 'Do not select an account yet.',
authorise_open: 'Open Nintendo Account authorisation',
response_heading: '2. Enter the callback link',
response_help_1: 'On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "{{url}}".',
response_help_2: 'If you are adding a child account linked to your account, click "Select this person" next to their account to sign in as the child account, then with only the child account showing right click "Select this person" and copy the link.',
cancel: 'Cancel',
save: 'Add account',
};

View File

@ -1,5 +1,6 @@
import { i18n } from 'i18next';
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
import { BrowserWindow, Menu, MenuItem, shell } from './electron.js';
import { app, BrowserWindow, Menu, MenuItem, shell } from './electron.js';
import { App } from './index.js';
let appinstance: App | null;
@ -8,90 +9,107 @@ export function setAppInstance(app: App) {
appinstance = app;
}
const menu_app = new MenuItem({
role: 'appMenu',
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: 'Preferences',
accelerator: 'CommandOrControl+,',
click: () => {
appinstance?.showPreferencesWindow();
function createAppMenuItems(i18n?: i18n) {
const menu_app = new MenuItem({
role: 'appMenu',
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: i18n?.t('app_menu:preferences') ?? 'Preferences',
accelerator: 'CommandOrControl+,',
click: () => {
appinstance?.showPreferencesWindow();
},
},
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
});
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
});
const menu_file = new MenuItem({role: 'fileMenu'});
const menu_edit = new MenuItem({role: 'editMenu'});
const menu_file = new MenuItem({role: 'fileMenu'});
const menu_edit = new MenuItem({role: 'editMenu'});
const menu_view = new MenuItem({
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
});
const menu_window = new MenuItem({role: 'windowMenu'});
const menu_window = new MenuItem({role: 'windowMenu'});
const menu_help = new MenuItem({
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
await shell.openExternal(GITLAB_URL);
},
},
{
label: 'Learn More (GitHub)',
click: async () => {
await shell.openExternal(GITHUB_MIRROR_URL);
},
},
{
label: 'Search Issues',
click: async () => {
await shell.openExternal(ISSUES_URL);
},
},
],
});
export const app_menu = Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [menu_app] : []),
menu_file,
menu_edit,
menu_view,
menu_window,
menu_help,
]);
export function createWindowMenu(window: BrowserWindow) {
const menu_view = new MenuItem({
label: 'View',
const menu_help = new MenuItem({
role: 'help',
submenu: [
{
label: 'Refresh',
label: i18n?.t('app_menu:learn_more') ?? 'Learn More',
click: async () => {
await shell.openExternal(GITLAB_URL);
},
},
{
label: i18n?.t('app_menu:learn_more_github') ?? 'Learn More (GitHub)',
click: async () => {
await shell.openExternal(GITHUB_MIRROR_URL);
},
},
{
label: i18n?.t('app_menu:search_issues') ?? 'Search Issues',
click: async () => {
await shell.openExternal(ISSUES_URL);
},
},
],
});
return {menu_app, menu_file, menu_edit, menu_window, menu_help};
}
function createAppMenu(i18n?: i18n) {
const {menu_app, menu_file, menu_edit, menu_window, menu_help} = createAppMenuItems(i18n);
const menu_view = new MenuItem({
label: i18n?.t('app_menu:view') ?? 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
});
return Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [menu_app] : []),
menu_file,
menu_edit,
menu_view,
menu_window,
menu_help,
]);
}
let app_menu = createAppMenu();
const menu_window_supports_refresh = new WeakSet<BrowserWindow>();
export function createWindowMenu(window: BrowserWindow, i18n?: i18n) {
menu_window_supports_refresh.add(window);
const {menu_app, menu_file, menu_edit, menu_window, menu_help} = createAppMenuItems(i18n);
const menu_view = new MenuItem({
label: i18n?.t('app_menu:view') ?? 'View',
submenu: [
{
id: 'window_refresh',
label: i18n?.t('app_menu:refresh') ?? 'Refresh',
click: (menuItem, browserWindow, event) => {
if (browserWindow === window) {
if (browserWindow && menu_window_supports_refresh.has(browserWindow)) {
browserWindow.webContents.send('nxapi:window:refresh');
} else {
browserWindow?.webContents.reload();
@ -119,3 +137,30 @@ export function createWindowMenu(window: BrowserWindow) {
menu_help,
]);
}
const menus = new WeakMap<BrowserWindow, Menu>();
app.on('browser-window-focus', (event, window) => {
Menu.setApplicationMenu(menus.get(window) ?? app_menu);
});
app.on('browser-window-blur', (event, window) => {
if (!BrowserWindow.getFocusedWindow()) {
Menu.setApplicationMenu(app_menu);
}
});
export function setWindowMenu(window: BrowserWindow, menu: Menu) {
menus.set(window, menu);
if (window.isFocused()) Menu.setApplicationMenu(app_menu);
}
export function updateMenuLanguage(i18n: i18n) {
app_menu = createAppMenu(i18n);
for (const window of BrowserWindow.getAllWindows()) {
if (!menus.has(window)) continue;
menus.set(window, createWindowMenu(window, i18n));
}
Menu.setApplicationMenu(menus.get(BrowserWindow.getFocusedWindow()!) ?? app_menu);
}

View File

@ -1,9 +1,10 @@
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions } from './electron.js';
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions, Menu } from './electron.js';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { i18n } from 'i18next';
import { init as initGlobals } from '../../common/globals.js';
import MenuApp from './menu.js';
import { handleOpenWebServiceUri } from './webservices.js';
@ -13,13 +14,14 @@ import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType }
import { initStorage, paths } from '../../util/storage.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import { setupIpc } from './ipc.js';
import { sendToAllWindows, setupIpc } from './ipc.js';
import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { askUserForUri } from './util.js';
import { setAppInstance } from './app-menu.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';
const debug = createDebug('app:main');
@ -58,7 +60,7 @@ export class App {
readonly updater = new Updater();
menu: MenuApp | null = null;
constructor(storage: persist.LocalStorage) {
constructor(storage: persist.LocalStorage, readonly i18n: i18n) {
this.store = new Store(this, storage);
this.monitors = new PresenceMonitorManager(this);
}
@ -115,6 +117,42 @@ export class App {
return this.preferences_window = window;
}
static async createI18n() {
const i18n = createI18n();
const language = this.detectSystemLanguage();
debug('Initialising i18n with language %s', language);
await i18n.init({lng: language ?? undefined});
await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri']);
return i18n;
}
static detectSystemLanguage() {
const languages = 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;
}
return null;
}
}
function setAboutPanelOptions(i18n?: i18n) {
app.setAboutPanelOptions({
applicationName: 'nxapi-app',
applicationVersion: process.platform === 'darwin' ? version : version +
(!release ? '-' + (git?.revision.substr(0, 8) ?? '?') : ''),
version: git?.revision.substr(0, 8) ?? '?',
authors: ['Samuel Elliott'],
website: GITLAB_URL,
credits: i18n?.t('app:credits') ?? CREDITS_NOTICE,
copyright: i18n?.t('app:licence') ?? LICENCE_NOTICE,
});
}
export async function init() {
@ -128,28 +166,23 @@ export async function init() {
initGlobals();
addUserAgent('nxapi-app (Chromium ' + process.versions.chrome + '; Electron ' + process.versions.electron + ')');
app.setAboutPanelOptions({
applicationName: 'nxapi-app',
applicationVersion: process.platform === 'darwin' ? version : version +
(!release ? '-' + (git?.revision.substr(0, 8) ?? '?') : ''),
version: git?.revision.substr(0, 8) ?? '?',
authors: ['Samuel Elliott'],
website: GITLAB_URL,
credits: CREDITS_NOTICE,
copyright: LICENCE_NOTICE,
});
setAboutPanelOptions();
app.configureHostResolver({enableBuiltInResolver: false});
const storage = await initStorage(process.env.NXAPI_DATA_PATH ?? paths.data);
const appinstance = new App(storage);
const [storage, i18n] = await Promise.all([
initStorage(process.env.NXAPI_DATA_PATH ?? paths.data),
App.createI18n(),
]);
const appinstance = new App(storage, i18n);
// @ts-expect-error
globalThis.app = appinstance;
setAboutPanelOptions(i18n);
setAppInstance(appinstance);
updateMenuLanguage(i18n);
setupIpc(appinstance, ipcMain);
app.configureHostResolver({enableBuiltInResolver: false});
if (process.platform === 'win32') {
app.setAppUserModelId('uk.org.fancy.nxapi.app');
}
@ -159,6 +192,14 @@ export async function init() {
const menu = new MenuApp(appinstance);
appinstance.menu = menu;
i18n.on('languageChanged', language => {
debug('Language changed', language);
sendToAllWindows('nxapi:app:update-language', language);
i18n.loadNamespaces('app').then(() => setAboutPanelOptions(i18n));
i18n.loadNamespaces('app_menu').then(() => updateMenuLanguage(i18n));
});
app.on('second-instance', (event, command_line, working_directory, additional_data) => {
debug('Second instance', command_line, working_directory, additional_data);
@ -220,26 +261,26 @@ function tryHandleUrl(app: App, url: string) {
}
if (url.match(/^com\.nintendo\.znca:\/\/(znca\/)?game\/(\d+)\/?($|\?|\#)/i)) {
handleOpenWebServiceUri(app.store, url);
handleOpenWebServiceUri(app, url);
return true;
}
if (url.match(/^com\.nintendo\.znca:\/\/(znca\/)?friendcode\/(\d{4}-\d{4}-\d{4})\/([A-Za-z0-9]{10})($|\?|\#)/i)) {
handleOpenFriendCodeUri(app.store, url);
handleOpenFriendCodeUri(app, url);
return true;
}
return false;
}
export async function handleOpenFriendCodeUri(store: Store, uri: string) {
export async function handleOpenFriendCodeUri(app: App, uri: string) {
const match = uri.match(/^com\.nintendo\.znca:\/\/(znca\/)friendcode\/(\d{4}-\d{4}-\d{4})\/([A-Za-z0-9]{10})($|\?|\#)/i);
if (!match) return;
const friendcode = match[2];
const hash = match[3];
const selected_user = await askUserForUri(store, uri, 'Select a user to add friends');
const selected_user = await askUserForUri(app, uri, app.i18n.t('handle_uri:friend_code_select'));
if (!selected_user) return;
createWindow(WindowType.ADD_FRIEND, {

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import createDebug from 'debug';
import { User } from 'discord-rpc';
@ -7,7 +7,7 @@ import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItemOptions, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App, login_item_options } from './index.js';
import { App } from './index.js';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
import { DiscordPresence } from '../../discord/types.js';
@ -25,6 +25,7 @@ const shown_modal_windows = new WeakSet<BrowserWindow>();
export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const store = appinstance.store;
const storage = appinstance.store.storage;
const t = appinstance.i18n.getFixedT(null, 'menus');
ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender));
@ -171,20 +172,23 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
(buildUserMenu(appinstance, user, nso, moon, BrowserWindow.fromWebContents(e.sender) ?? undefined)
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: 'Add Nintendo Switch Online account', click:
new MenuItem({label: t('add_account.add_account_coral')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(storage, !event.shiftKey)}),
new MenuItem({label: 'Add Nintendo Switch Parental Controls account', click:
new MenuItem({label: t('add_account.add_account_moon')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(storage, !event.shiftKey)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
new MenuItem({label: 'Share', role: 'shareMenu', sharingItem: {texts: ['SW-' + fc.id]}}),
new MenuItem({label: 'Copy', click: () => clipboard.writeText('SW-' + fc.id)}),
new MenuItem({label: t('friend_code.share')!, role: 'shareMenu', sharingItem: {texts: ['SW-' + fc.id]}}),
new MenuItem({label: t('friend_code.copy')!, click: () => clipboard.writeText('SW-' + fc.id)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: fc.regenerable ? 'Regenerate using a Nintendo Switch console' :
'Can be regenerated at ' + new Date(fc.regenerableAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: fc.regenerable ? t('friend_code.friend_code_regenerable')! :
t('friend_code.friend_code_regenerable_at', {
date: new Date(fc.regenerableAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
(buildFriendMenu(appinstance, user, nso, friend)
@ -210,41 +214,43 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
store.on('update-discord-user', (u: User) => sendToAllWindows('nxapi:discord:user', u));
}
function sendToAllWindows(channel: string, ...args: any[]) {
export function sendToAllWindows(channel: string, ...args: any[]) {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(channel, ...args);
}
}
function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean, window?: BrowserWindow) {
const t = app.i18n.getFixedT(null, 'menus', 'user');
const dm = app.monitors.getActiveDiscordPresenceMonitor();
const monitor = app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === user.id);
return Menu.buildFromTemplate([
new MenuItem({label: 'Nintendo Account ID: ' + user.id, enabled: false}),
new MenuItem({label: t('na_id', {id: user.id})!, enabled: false}),
...(nso ? [
new MenuItem({label: 'Coral ID: ' + nso.id, enabled: false}),
new MenuItem({label: 'NSA ID: ' + nso.nsaId, enabled: false}),
new MenuItem({label: t('coral_id', {id: nso.id})!, enabled: false}),
new MenuItem({label: t('nsa_id', {id: nso.nsaId})!, enabled: false}),
new MenuItem({type: 'separator'}),
...(monitor?.presence_user === nso.nsaId ? [
new MenuItem({label: 'Disable Discord presence',
new MenuItem({label: t('discord_disable')!,
click: () => app.menu?.setActiveDiscordPresenceUser(null)}),
] : monitor?.presence_user ? [
new MenuItem({label: 'Discord presence enabled for ' +
new MenuItem({label: t('discord_enabled_for', {name:
monitor.user?.friends.result.friends.find(f => f.nsaId === monitor.presence_user)?.name ??
monitor.presence_user,
monitor.presence_user})!,
enabled: false}),
new MenuItem({label: 'Disable Discord presence',
new MenuItem({label: t('discord_disable')!,
click: () => app.menu?.setActiveDiscordPresenceUser(null)}),
] : dm?.presence_user === nso.nsaId ? [
new MenuItem({label: 'Discord presence enabled using ' +
new MenuItem({label: t('discord_enabled_via', {name:
dm.data.user.nickname +
(dm.data.user.nickname!== dm.data.nsoAccount.user.name ? '/' + dm.data.nsoAccount.user.name : ''),
(dm.data.user.nickname !== dm.data.nsoAccount.user.name ?
'/' + dm.data.nsoAccount.user.name : '')})!,
enabled: false}),
new MenuItem({label: 'Disable Discord presence',
new MenuItem({label: t('discord_disable')!,
click: () => app.menu?.setActiveDiscordPresenceUser(null)}),
] : [
new MenuItem({label: 'Enable Discord presence for this user...',
new MenuItem({label: t('discord_enable')!,
click: () => createWindow(WindowType.DISCORD_PRESENCE, {
friend_nsa_id: nso.nsaId,
}, {
@ -261,13 +267,13 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
maxHeight: 300,
})}),
]),
new MenuItem({label: 'Enable friend notifications', type: 'checkbox',
new MenuItem({label: t('friend_notifications_enable')!, type: 'checkbox',
checked: monitor?.friend_notifications,
click: () => app.menu?.setFriendNotificationsActive(user.id, !monitor?.friend_notifications)}),
new MenuItem({label: 'Update now', enabled: !!monitor,
new MenuItem({label: t('refresh')!, enabled: !!monitor,
click: () => monitor?.skipIntervalInCurrentLoop(true)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: 'Add friend',
new MenuItem({label: t('add_friend')!,
click: () => createWindow(WindowType.ADD_FRIEND, {
user: user.id,
}, {
@ -285,11 +291,12 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
})}),
] : []),
new MenuItem({type: 'separator'}),
new MenuItem({label: 'Use the nxapi command to remove this user', enabled: false}),
new MenuItem({label: t('remove_help')!, enabled: false}),
]);
}
function buildFriendMenu(app: App, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) {
const t = app.i18n.getFixedT(null, 'menus', 'friend');
const discord_presence_source = app.monitors.getDiscordPresenceSource();
const discord_presence_active = !!discord_presence_source && 'na_id' in discord_presence_source &&
discord_presence_source.na_id === user.id && discord_presence_source.friend_nsa_id === friend.nsaId;
@ -297,34 +304,54 @@ function buildFriendMenu(app: App, user: NintendoAccountUser, nso: CurrentUser,
return Menu.buildFromTemplate([
...(!friend.presence.updatedAt ? [
] : friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING ? [
new MenuItem({label: 'Online', enabled: false}),
new MenuItem({label: t('presence_online')!, enabled: false}),
...('name' in friend.presence.game ? [
new MenuItem({label: friend.presence.game.name, click: () =>
shell.openExternal((friend.presence.game as Game).shopUri)}),
...(friend.presence.game.sysDescription ? [
new MenuItem({label: friend.presence.game.sysDescription, enabled: false}),
] : []),
new MenuItem({label: 'First played: ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: 'Play time: ' + hrduration(friend.presence.game.totalPlayTime), enabled: false}),
new MenuItem({label: t('game_first_played', {
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: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: t('presence_updated', {
date: new Date(friend.presence.updatedAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
new MenuItem({type: 'separator'}),
] : friend.presence.state === PresenceState.INACTIVE ? [
new MenuItem({label: 'Offline (console online)', enabled: false}),
new MenuItem({label: t('presence_inactive')!, enabled: false}),
...(friend.presence.logoutAt ? [
new MenuItem({label: 'Logout time: ' + new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: t('presence_logout_time', {
date: new Date(friend.presence.logoutAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
] : []),
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: t('presence_updated', {
date: new Date(friend.presence.updatedAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
new MenuItem({type: 'separator'}),
] : [
new MenuItem({label: 'Offline', enabled: false}),
...(friend.presence.logoutAt ? [
new MenuItem({label: 'Logout time: ' + new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: t('presence_logout_time', {
date: new Date(friend.presence.logoutAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
] : []),
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: t('presence_updated', {
date: new Date(friend.presence.updatedAt * 1000),
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
new MenuItem({type: 'separator'}),
]),
new MenuItem({label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active, click: () =>
new MenuItem({label: t('discord_presence_enable')!, type: 'checkbox', checked: discord_presence_active, click: () =>
app.monitors.setDiscordPresenceSource(discord_presence_active ? null :
{na_id: user.id, friend_nsa_id: friend.nsaId})}),
]);

View File

@ -36,13 +36,18 @@ export default class MenuApp {
this.updateMenu();
});
this.updateMenu();
app.i18n.on('languageChanged', language => this.updateMenu());
}
async updateMenu() {
await this.app.i18n.loadNamespaces('menu_app');
const t = this.app.i18n.getFixedT(null, 'menu_app');
const menu = new Menu();
const ids = await this.app.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Nintendo Switch Online', enabled: false}));
menu.append(new MenuItem({label: t('coral_heading')!, enabled: false}));
const discord_presence_monitor = this.getActiveDiscordPresenceMonitor();
@ -62,26 +67,26 @@ export default class MenuApp {
const item = new MenuItem({
label: data.nsoAccount.user.name,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: 'Coral ID: ' + data.nsoAccount.user.id, enabled: false},
{label: 'NSA ID: ' + data.nsoAccount.user.nsaId, enabled: false},
{label: t('na_id', {id: data.user.id})!, enabled: false},
{label: t('coral_id', {id: data.nsoAccount.user.id})!, enabled: false},
{label: t('nsa_id', {id: data.nsoAccount.user.nsaId})!, enabled: false},
{type: 'separator'},
{label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active,
{label: t('discord_presence_enable')!, type: 'checkbox', checked: discord_presence_active,
enabled: discord_presence_active,
click: () => this.setActiveDiscordPresenceUser(discord_presence_active ? null : data.user.id)},
{label: 'Enable notifications for this user\'s presence', type: 'checkbox',
{label: t('user_notifications_enable')!, type: 'checkbox',
checked: monitor?.user_notifications,
enabled: !!monitor?.user_notifications,
click: () => this.setUserNotificationsActive(data.user.id, !monitor?.user_notifications)},
{label: 'Enable notifications for friends of this user\'s presence', type: 'checkbox',
{label: t('friend_notifications_enable')!, type: 'checkbox',
checked: monitor?.friend_notifications,
click: () => this.setFriendNotificationsActive(data.user.id, !monitor?.friend_notifications)},
{label: 'Update now', enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop(true)},
{label: t('refresh')!, enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop(true)},
{type: 'separator'},
{label: 'Add friend', click: () => this.showAddFriendWindow(data.user.id)},
{label: t('add_friend')!, click: () => this.showAddFriendWindow(data.user.id)},
...(webservices.length ? [
{type: 'separator'},
{label: 'Web services', enabled: false},
{label: t('web_services')!, enabled: false},
...webservices as any,
] : []),
],
@ -90,9 +95,9 @@ export default class MenuApp {
menu.append(item);
}
menu.append(new MenuItem({label: 'Add account', click: this.addNsoAccount}));
menu.append(new MenuItem({label: t('add_account')!, click: this.addNsoAccount}));
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Nintendo Switch Parental Controls', enabled: false}));
menu.append(new MenuItem({label: t('moon_heading')!, enabled: false}));
for (const id of ids ?? []) {
const token = await this.app.store.storage.getItem('NintendoAccountToken-pctl.' + id) as string | undefined;
@ -103,23 +108,23 @@ export default class MenuApp {
const item = new MenuItem({
label: data.user.nickname,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: t('na_id', {id: data.user.id})!, enabled: false},
],
});
menu.append(item);
}
menu.append(new MenuItem({label: 'Add account', click: this.addPctlAccount}));
menu.append(new MenuItem({label: t('add_account')!, click: this.addPctlAccount}));
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Show main window', click: () => this.app.showMainWindow()}));
menu.append(new MenuItem({label: 'Preferences', click: () => this.app.showPreferencesWindow()}));
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: 'Dump notifications state', click: () => {
debug('Accounts', this.app.monitors.notifications.accounts);
debug('Friends', this.app.monitors.notifications.onlinefriends);
}}));
menu.append(new MenuItem({label: 'Quit', click: () => app.quit()}));
menu.append(new MenuItem({label: t('quit')!, click: () => app.quit()}));
this.tray.setContextMenu(menu);
}

View File

@ -1,5 +1,6 @@
import { dialog, Notification } from './electron.js';
import createDebug from 'debug';
import { i18n } from 'i18next';
import { CurrentUser, Friend, Game, CoralErrorResponse } from '../../api/coral-types.js';
import { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
@ -16,11 +17,13 @@ const debug = createDebug('app:main:monitor');
export class PresenceMonitorManager {
monitors: (EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor)[] = [];
notifications = new ElectronNotificationManager();
notifications: NotificationManager;
constructor(
public app: App
) {}
) {
this.notifications = new ElectronNotificationManager(app.i18n);
}
async start(id: string, callback?: (monitor: EmbeddedPresenceMonitor, firstRun: boolean) => Promise<void> | void) {
debug('Starting monitor', id);
@ -363,7 +366,6 @@ export class PresenceMonitorManager {
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
notifications = new ElectronNotificationManager();
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
@ -422,7 +424,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
}
export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
notifications = new ElectronNotificationManager();
notifications: NotificationManager | null = null;
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
@ -481,13 +483,21 @@ export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
}
export class ElectronNotificationManager extends NotificationManager {
t: ReturnType<i18n['getFixedT']>;
constructor(i18n: i18n) {
super();
this.t = i18n.getFixedT(null, 'notifications');
}
async onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
body: this.t('playing', {name: currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : '')})!,
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
@ -495,7 +505,7 @@ export class ElectronNotificationManager extends NotificationManager {
async onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
new Notification({
title: friend.name,
body: 'Offline',
body: this.t('offline')!,
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
@ -505,8 +515,8 @@ export class ElectronNotificationManager extends NotificationManager {
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
body: this.t('playing', {name: currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : '')})!,
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
@ -516,8 +526,8 @@ export class ElectronNotificationManager extends NotificationManager {
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
body: this.t('playing', {name: currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : '')})!,
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}

View File

@ -3,7 +3,7 @@ import path from 'node:path';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import { dir } from '../../util/product.js';
import { Store } from './index.js';
import { App } from './index.js';
import { SavedToken } from '../../common/auth/coral.js';
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
@ -26,10 +26,10 @@ export async function tryGetNativeImageFromUrl(url: URL | string, useragent?: st
return undefined;
}
export async function askUserForUri(store: Store, uri: string, prompt: string): Promise<[string, SavedToken] | null> {
export async function askUserForUri(app: App, uri: string, prompt: string): Promise<[string, SavedToken] | null> {
const menu = new Menu();
const ids = await store.storage.getItem('NintendoAccountIds') as string[] | undefined;
const ids = await app.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: prompt, enabled: false}));
menu.append(new MenuItem({label: uri, enabled: false}));
menu.append(new MenuItem({type: 'separator'}));
@ -37,9 +37,9 @@ export async function askUserForUri(store: Store, uri: string, prompt: string):
let selected_user: [string, SavedToken] | null = null;
const items = await Promise.all(ids?.map(async id => {
const token = await store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
const token = await app.store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
if (!token) return;
const data = await store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
const data = await app.store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) return;
return new MenuItem({
@ -55,7 +55,7 @@ export async function askUserForUri(store: Store, uri: string, prompt: string):
for (const item of items) if (item) menu.append(item);
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Cancel', click: (i, w) => menu.closePopup(w)}));
menu.append(new MenuItem({label: app.i18n.t('handle_uri:cancel')!, click: (i, w) => menu.closePopup(w)}));
const window = new BrowserWindow({show: false});
// Add a delay to prevent the menu being closed immediately

View File

@ -8,7 +8,7 @@ import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage,
import fetch from 'node-fetch';
import CoralApi from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { Store } from './index.js';
import { App, Store } from './index.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/coral.js';
import { createWebServiceWindow } from './windows.js';
@ -170,29 +170,31 @@ function isWebServiceUrlAllowed(webservice: WebService, url: string | URL) {
return false;
}
export async function handleOpenWebServiceUri(store: Store, uri: string) {
export async function handleOpenWebServiceUri(app: App, uri: string) {
const match = uri.match(/^com\.nintendo\.znca:\/\/(znca\/)game\/(\d+)\/?($|\?|\#)/i);
if (!match) return;
const webservice_id = parseInt(match[2]);
const selected_user = await askUserForWebServiceUri(store, uri);
const selected_user = await askUserForWebServiceUri(app, uri);
if (!selected_user) return;
const {nso, data, webservices} = await store.users.get(selected_user[0]);
const {nso, data, webservices} = await app.store.users.get(selected_user[0]);
const webservice = webservices.result.find(w => w.id === webservice_id);
if (!webservice) {
dialog.showErrorBox('Invalid web service', 'The URL did not reference an existing web service.\n\n' +
dialog.showErrorBox(app.i18n.t('handle_uri:web_service_invalid_title'),
app.i18n.t('handle_uri:web_service_invalid_detail') +
'\n\n' +
uri);
return;
}
return openWebService(store, selected_user[0], nso, data, webservice, new URL(uri).search.substr(1));
return openWebService(app.store, selected_user[0], nso, data, webservice, new URL(uri).search.substr(1));
}
function askUserForWebServiceUri(store: Store, uri: string) {
return askUserForUri(store, uri, 'Select a user to open this web service');
function askUserForWebServiceUri(app: App, uri: string) {
return askUserForUri(app, uri, app.i18n.t('handle_uri:web_service_select'));
}
export interface WebServiceData {

View File

@ -1,22 +1,12 @@
import { app, BrowserWindow, BrowserWindowConstructorOptions, Menu, nativeTheme, session, WebContents } from './electron.js';
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, session, WebContents } from './electron.js';
import * as path from 'node:path';
import { dev } from '../../util/product.js';
import { WindowConfiguration, WindowType } from '../common/types.js';
import { bundlepath } from './util.js';
import { app_menu, createWindowMenu } from './app-menu.js';
import { createWindowMenu, setWindowMenu } from './app-menu.js';
import { WebService } from '../../api/coral-types.js';
const windows = new WeakMap<WebContents, WindowConfiguration>();
const menus = new WeakMap<BrowserWindow, Menu>();
app.on('browser-window-focus', (event, window) => {
Menu.setApplicationMenu(menus.get(window) ?? app_menu);
});
app.on('browser-window-blur', (event, window) => {
if (!BrowserWindow.getFocusedWindow()) {
Menu.setApplicationMenu(app_menu);
}
});
export function createWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
@ -43,7 +33,7 @@ export function createWindow<T extends WindowType>(
};
windows.set(window.webContents, data);
menus.set(window, createWindowMenu(window));
setWindowMenu(window, createWindowMenu(window));
window.loadFile(path.join(bundlepath, 'index.html'));
if (dev) window.webContents.openDevTools();
@ -84,7 +74,7 @@ export function createWebServiceWindow(nsa_id: string, webservice: WebService, t
},
});
menus.set(window, createWindowMenu(window));
setWindowMenu(window, createWindowMenu(window));
window.loadURL('about:blank');
if (dev) window.webContents.openDevTools();

View File

@ -89,6 +89,7 @@ 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'));

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"strict": true,
"target": "es2020",
"target": "es2021",
"module": "node16",
"jsx": "react",
"moduleResolution": "node16",
@ -12,7 +12,7 @@
"outDir": "dist",
"sourceMap": true,
"lib": [
"es2020",
"es2021",
"dom"
],
"stripInternal": true,