Add friend code lookup and friend requests to the Electron app

This commit is contained in:
Samuel Elliott 2022-08-13 00:21:03 +01:00
parent ca36935f78
commit 4c5203e50e
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
17 changed files with 560 additions and 68 deletions

View File

@ -0,0 +1,334 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, NativeSyntheticEvent, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, useColorScheme, View } from 'react-native';
import { Friend, FriendCodeUser } from '../../../api/coral-types.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';
export interface AddFriendProps {
user: string;
friendcode?: string;
}
enum SendFriendRequestState {
NOT_LOADING,
SENDING,
SENT,
ERROR,
}
type SendFriendRequestStateArray =
[SendFriendRequestState.NOT_LOADING] |
[SendFriendRequestState.SENDING, FriendCodeUser, string] |
[SendFriendRequestState.SENT, FriendCodeUser, string, Friend | null] |
[SendFriendRequestState.ERROR, FriendCodeUser, string, Error];
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, []);
const [token] = useAsync(useCallback(() => ipc.getNintendoAccountCoralToken(props.user), [ipc, props.user]));
const [user] = useAsync(useCallback(() => token ?
ipc.getSavedCoralToken(token) : Promise.resolve(null), [ipc, token]));
const [friendcode, setFriendCode] = useState(props.friendcode ?? '');
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]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === target_user?.nsaId);
const showLookupErrorDetails = useCallback(() => {
alert(lookup_error);
}, [lookup_error]);
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]);
try {
const {result, friend} = await ipc.addNsoFriend(token, target_user.nsaId);
setSendFriendRequestState([SendFriendRequestState.SENT, target_user, friendcode, friend]);
} catch (err) {
setSendFriendRequestState([SendFriendRequestState.ERROR, target_user, friendcode, err as Error]);
}
}, [token, target_user, friendcode, send_state]);
const showSendFriendRequestErrorDetails = useCallback(() => {
if (send_state[0] !== SendFriendRequestState.ERROR) return;
alert(send_state[3]);
}, [send_state]);
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);
}, []);
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)} />
</View>
</Root>;
}
return <Root title="Add friend" titleUser={user} scrollable autoresize>
<View style={styles.main}>
{show_friendcode_field ? <>
<Text style={theme.text}>Add friend</Text>
<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.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>
</TouchableOpacity> : null}
{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 ?
<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}
</View>
</View>
</Root>;
}
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingVertical: 50,
paddingHorizontal: 20,
justifyContent: 'center',
},
main: {
flex: 1,
paddingVertical: 20,
paddingHorizontal: 20,
},
help: {
marginTop: 8,
fontSize: 13,
opacity: 0.7,
},
friendCodeInputContainer: {
marginTop: 16,
flexDirection: 'row',
justifyContent: 'center',
},
friendCodeInput: {
marginTop: 0,
flex: 1,
fontSize: 18,
fontFamily: 'monospace',
paddingVertical: 8,
paddingHorizontal: 14,
},
activityIndicator: {
marginLeft: 10,
},
iconTouchable: {
marginLeft: 10,
justifyContent: 'center',
},
icon: {
fontSize: 20,
},
textInput: {
marginTop: 8,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 3,
fontSize: 13,
},
lookupErrorNoFriendCodeField: {
},
lookupErrorIcon: {
marginRight: 10,
},
targetUser: {
marginTop: 20,
padding: 14,
borderRadius: 3,
flexDirection: 'row',
},
targetUserNoFriendCodeField: {
marginTop: 0,
},
targetUserImage: {
marginRight: 14,
width: 100,
},
targetUserDetail: {
flex: 1,
},
targetUserName: {
fontSize: 18,
fontWeight: '500',
marginBottom: 8,
},
targetUserNsaId: {
fontSize: 13,
opacity: 0.7,
},
targetUserNsaIdValue: {
fontFamily: 'monospace',
userSelect: 'all',
},
targetUserCoralId: {
fontSize: 13,
opacity: 0.7,
},
targetUserCoralIdValue: {
fontFamily: 'monospace',
userSelect: 'all',
},
friendRequestState: {
marginTop: 10,
},
friendRequestActivityIndicator: {
marginRight: 10,
},
friendRequestStateIcon: {
marginRight: 10,
},
buttons: {
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
},
button: {
marginLeft: 10,
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
textInput: {
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
color: TEXT_COLOUR_LIGHT,
},
targetUser: {
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
textInput: {
backgroundColor: HIGHLIGHT_COLOUR_DARK,
color: TEXT_COLOUR_DARK,
},
targetUser: {
backgroundColor: HIGHLIGHT_COLOUR_DARK,
},
});

View File

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

View File

@ -24,9 +24,9 @@ export default function Friend(props: FriendProps) {
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const [token] = useAsync(useCallback(() => ipc.getNintendoAccountNsoToken(props.user), [ipc, props.user]));
const [token] = useAsync(useCallback(() => ipc.getNintendoAccountCoralToken(props.user), [ipc, props.user]));
const [user] = useAsync(useCallback(() => token ?
ipc.getSavedNsoToken(token) : Promise.resolve(null), [ipc, token]));
ipc.getSavedCoralToken(token) : Promise.resolve(null), [ipc, token]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === props.friend);

View File

@ -3,10 +3,12 @@ import { config } from './ipc.js';
import App from './main/index.js';
import Friend from './friend/index.js';
import DiscordSetup from './discord/index.js';
import AddFriend from './add-friend/index.js';
AppRegistry.registerComponent('App', () => App);
AppRegistry.registerComponent('Friend', () => Friend);
AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
AppRegistry.registerComponent('AddFriend', () => AddFriend);
const style = window.document.createElement('style');

View File

@ -36,7 +36,7 @@ function DiscordPresenceSourceCoral(props: {
source: DiscordPresenceSourceCoral;
}) {
const [token] = useAsync(useCallback(() =>
ipc.getNintendoAccountNsoToken(props.source.na_id), [ipc, props.source.na_id]));
ipc.getNintendoAccountCoralToken(props.source.na_id), [ipc, props.source.na_id]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === props.source.friend_nsa_id);

View File

@ -1,10 +1,11 @@
import React, { useCallback } from 'react';
import { Image, ImageStyle, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ipc from '../ipc.js';
import { useColourScheme, User } from '../util.js';
import { useAccentColour, useColourScheme, User } from '../util.js';
import { Friend, Presence, PresenceState } from '../../../api/coral-types.js';
import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
import Section, { HEADER_SIZE } from './section.js';
import AddOutline from '../components/icons/add-outline.js';
export default function Friends(props: {
user: User;
@ -13,6 +14,15 @@ export default function Friends(props: {
error?: Error;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const showAddFriendModal = useCallback(() => {
ipc.showAddFriendModal({user: props.user.user.id});
}, [props.user.user.id]);
const header_buttons = <TouchableOpacity onPress={showAddFriendModal} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><AddOutline /></Text>
</TouchableOpacity>;
const onFriendCodeContextMenu = useCallback(() => {
ipc.showFriendCodeMenu(props.user.nso!.nsoAccount.user.links.friendCode);
@ -24,7 +34,7 @@ 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}>
return <Section title="Friends" 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} />)}
@ -93,6 +103,13 @@ function FriendPresence(props: {
}
const styles = StyleSheet.create({
iconTouchable: {
marginLeft: 10,
},
icon: {
fontSize: HEADER_SIZE,
},
footer: {
paddingBottom: 16,
paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,

View File

@ -19,13 +19,13 @@ export default function Main(props: {
const accent_colour = useAccentColour();
const [announcements, announcements_error, announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
ipc.getCoralAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [friends, friends_error, friends_state, forceRefreshFriends] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoFriends(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [webservices, webservices_error, webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
ipc.getWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [active_event, active_event_error, active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
ipc.getCoralActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const loading = announcements_state === RequestState.LOADING ||
friends_state === RequestState.LOADING ||
@ -49,7 +49,7 @@ export default function Main(props: {
if (active_event_error) alert(active_event_error.stack ?? active_event_error.message);
}, [friends_error, webservices_error, active_event_error]);
if (!announcements || !friends || !webservices || !active_event) {
if (!friends || !webservices || !active_event) {
if (loading) {
return <View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + accent_colour} />
@ -99,7 +99,7 @@ function MoonOnlyUser() {
<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>
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} primary />
<Button title="Login" onPress={() => ipc.addCoralAccount()} color={'#' + accent_colour} primary />
</View>
</View>
</Section>;

View File

@ -9,6 +9,7 @@ export default function Section(props: React.PropsWithChildren<{
title: string;
loading?: boolean;
error?: Error;
headerButtons?: React.ReactNode;
}>) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
@ -25,13 +26,14 @@ export default function Section(props: React.PropsWithChildren<{
props.error ? <TouchableOpacity onPress={showErrorDetails} style={styles.iconTouchable}>
<Text style={[styles.icon, {color: '#' + accent_colour}]}><Warning /></Text>
</TouchableOpacity> : null}
{props.headerButtons}
</View>
{props.children}
</View>;
}
const HEADER_SIZE = ipc.platform === 'win32' ? 24 : 14;
export const HEADER_SIZE = ipc.platform === 'win32' ? 24 : 14;
const styles = StyleSheet.create({
container: {

View File

@ -208,10 +208,10 @@ export async function getAccounts() {
const accounts: User[] = [];
for (const id of ids ?? []) {
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
const nsotoken = await ipc.getNintendoAccountCoralToken(id);
const moontoken = await ipc.getNintendoAccountMoonToken(id);
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
const nso = nsotoken ? await ipc.getSavedCoralToken(nsotoken) ?? null : null;
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
if (!nso && !moon) continue;

View File

@ -4,12 +4,14 @@ export enum WindowType {
MAIN_WINDOW = 'App',
FRIEND = 'Friend',
DISCORD_PRESENCE = 'DiscordPresence',
ADD_FRIEND = 'AddFriend',
}
interface WindowProps {
[WindowType.MAIN_WINDOW]: import('../browser/main/index.js').AppProps;
[WindowType.FRIEND]: import('../browser/friend/index.js').FriendProps;
[WindowType.DISCORD_PRESENCE]: import('../browser/discord/index.js').DiscordSetupProps;
[WindowType.ADD_FRIEND]: import('../browser/add-friend/index.js').AddFriendProps;
}
export interface WindowConfiguration<T extends WindowType = WindowType> {

View File

@ -17,6 +17,7 @@ import Users, { CoralUser } from '../../common/users.js';
import { setupIpc } from './ipc.js';
import { dev, dir } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { askUserForUri } from './util.js';
const debug = createDebug('app:main');
@ -135,9 +136,40 @@ function tryHandleUrl(app: App, url: string) {
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);
return true;
}
return false;
}
export async function handleOpenFriendCodeUri(store: Store, 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');
if (!selected_user) return;
createWindow(WindowType.ADD_FRIEND, {
user: selected_user[1].user.id,
friendcode,
}, {
// show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}
class Updater {
private _cache: UpdateCacheData | null = null;
private _check: Promise<UpdateCacheData | null> | null = null;

View File

@ -16,6 +16,7 @@ import { defaultTitle } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import { EmbeddedPresenceMonitor } from './monitor.js';
import { AddFriendProps } from '../browser/add-friend/index.js';
const debug = createDebug('app:main:ipc');
@ -49,12 +50,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(store.storage).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(store.storage).then(u => u?.data.user.id));
ipcMain.handle('nxapi:nso:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:nso:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:nso:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
ipcMain.handle('nxapi:nso:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
ipcMain.handle('nxapi:nso:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
ipcMain.handle('nxapi:nso:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
ipcMain.handle('nxapi:coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:coral:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
ipcMain.handle('nxapi:coral:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
ipcMain.handle('nxapi:coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
ipcMain.handle('nxapi:coral:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
store.users.get(token).then(u => openWebService(store, token, u.nso, u.data, webservice, qs)
.catch(err => dialog.showMessageBox(BrowserWindow.fromWebContents(e.sender)!, {
type: 'error',
@ -72,7 +73,10 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
user_coral_id: u.data.nsoAccount.user.id,
}, {compact: true}),
}))));
ipcMain.handle('nxapi:nso:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
ipcMain.handle('nxapi:coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
ipcMain.handle('nxapi:coral:friendcodeurl', (e, token: string) => store.users.get(token).then(u => u.nso.getFriendCodeUrl()).then(r => r.result));
ipcMain.handle('nxapi:coral:friendcode', (e, token: string, friendcode: string, hash?: string) => store.users.get(token).then(u => u.nso.getUserByFriendCode(friendcode, hash)).then(r => r.result));
ipcMain.handle('nxapi:coral:addfriend', (e, token: string, nsaid: string) => store.users.get(token).then(u => u.addFriend(nsaid)));
ipcMain.handle('nxapi:window:showfriend', (e, props: FriendProps) => createWindow(WindowType.FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
@ -100,6 +104,19 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) => createWindow(WindowType.ADD_FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
const [curWidth, curHeight] = window.getSize();
@ -228,6 +245,23 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
click: () => app.menu?.setFriendNotificationsActive(user.id, !monitor?.friend_notifications)}),
new MenuItem({label: 'Update now', enabled: !!monitor,
click: () => monitor?.skipIntervalInCurrentLoop(true)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: 'Add friend',
click: () => createWindow(WindowType.ADD_FRIEND, {
user: user.id,
}, {
parent: window,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
})}),
] : []),
new MenuItem({type: 'separator'}),
new MenuItem({label: 'Use the nxapi command to remove this user', enabled: false}),

View File

@ -9,6 +9,8 @@ import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { dev, dir } from '../../util/product.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';
import { createWindow } from './windows.js';
import { WindowType } from '../common/types.js';
const debug = createDebug('app:main:menu');
@ -67,6 +69,8 @@ export default class MenuApp {
click: () => this.setFriendNotificationsActive(data.user.id, !monitor?.friend_notifications)},
{label: 'Update now', enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop(true)},
{type: 'separator'},
{label: 'Add friend', click: () => this.showAddFriendWindow(data.user.id)},
{type: 'separator'},
{label: 'Web services', enabled: false},
...await this.getWebServiceItems(token) as any,
],
@ -275,4 +279,20 @@ export default class MenuApp {
this.saveMonitorState();
this.updateMenu();
}
showAddFriendWindow(user: string) {
createWindow(WindowType.ADD_FRIEND, {
user,
}, {
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}
}

View File

@ -1,8 +1,10 @@
import { nativeImage } from './electron.js';
import { BrowserWindow, Menu, MenuItem, nativeImage } from './electron.js';
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 { SavedToken } from '../../common/auth/coral.js';
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
@ -23,3 +25,41 @@ 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> {
const menu = new Menu();
const ids = await 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'}));
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;
if (!token) return;
const data = await store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) return;
return new MenuItem({
label: data.nsoAccount.user.name,
click: (menuItem, browserWindow, event) => {
selected_user = [token, data];
menu.closePopup(browserWindow);
},
});
}) ?? []);
if (!items.length) return null;
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)}));
const window = new BrowserWindow({show: false});
await new Promise<void>(rs => menu.popup({callback: rs}));
window.destroy();
return selected_user;
}

View File

@ -12,6 +12,7 @@ import { Store } from './index.js';
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/coral.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } from './util.js';
const debug = createDebug('app:main:webservices');
@ -155,42 +156,8 @@ export async function handleOpenWebServiceUri(store: Store, uri: string) {
return openWebService(store, selected_user[0], nso, data, webservice, new URL(uri).search.substr(1));
}
async function askUserForWebServiceUri(store: Store, uri: string): Promise<[string, SavedToken] | null> {
const menu = new Menu();
const ids = await store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Select a user to open this web service', enabled: false}));
menu.append(new MenuItem({label: uri, enabled: false}));
menu.append(new MenuItem({type: 'separator'}));
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;
if (!token) return;
const data = await store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) return;
return new MenuItem({
label: data.nsoAccount.user.name,
click: (menuItem, browserWindow, event) => {
selected_user = [token, data];
menu.closePopup(browserWindow);
},
});
}) ?? []);
if (!items.length) return null;
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)}));
const window = new BrowserWindow({show: false});
await new Promise<void>(rs => menu.popup({callback: rs}));
window.destroy();
return selected_user;
function askUserForWebServiceUri(store: Store, uri: string) {
return askUserForUri(store, uri, 'Select a user to open this web service');
}
export interface WebServiceData {

View File

@ -8,11 +8,12 @@ import type { DiscordPresenceConfiguration, DiscordPresenceSource, WindowConfigu
import type { SavedToken } from '../../common/auth/coral.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
import type { Announcements, CurrentUser, Friend, GetActiveEventResult, WebService, WebServices } from '../../api/coral-types.js';
import type { Announcements, CoralSuccessResponse, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, GetActiveEventResult, WebService, WebServices } from '../../api/coral-types.js';
import type { DiscordPresence } from '../../discord/util.js';
import type { NintendoAccountUser } from '../../api/na.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { FriendProps } from '../browser/friend/index.js';
import { AddFriendProps } from '../browser/add-friend/index.js';
const debug = createDebug('app:preload');
@ -31,16 +32,19 @@ const ipc = {
checkUpdates: () => inv<UpdateCacheData | null>('update:check'),
listNintendoAccounts: () => inv<string[] | undefined>('accounts:list'),
addNsoAccount: () => inv<string>('accounts:add-coral'),
addCoralAccount: () => inv<string>('accounts:add-coral'),
addMoonAccount: () => inv<string>('accounts:add-moon'),
getNintendoAccountNsoToken: (id: string) => inv<string | undefined>('nso:gettoken', id),
getSavedNsoToken: (token: string) => inv<SavedToken | undefined>('nso:getcachedtoken', token),
getNsoAnnouncements: (token: string) => inv<Announcements>('nso:announcements', token),
getNsoFriends: (token: string) => inv<Friend[]>('nso:friends', token),
getNsoWebServices: (token: string) => inv<WebServices | undefined>('nso:webservices', token),
openWebService: (webservice: WebService, token: string, qs?: string) => inv<number>('nso:openwebservice', webservice, token, qs),
getNsoActiveEvent: (token: string) => inv<GetActiveEventResult>('nso:activeevent', token),
getNintendoAccountCoralToken: (id: string) => inv<string | undefined>('coral:gettoken', id),
getSavedCoralToken: (token: string) => inv<SavedToken | undefined>('coral:getcachedtoken', token),
getCoralAnnouncements: (token: string) => inv<Announcements>('coral:announcements', token),
getNsoFriends: (token: string) => inv<Friend[]>('coral:friends', token),
getWebServices: (token: string) => inv<WebServices | undefined>('coral:webservices', token),
openWebService: (webservice: WebService, token: string, qs?: string) => inv<number>('coral:openwebservice', webservice, token, qs),
getCoralActiveEvent: (token: string) => inv<GetActiveEventResult>('coral:activeevent', token),
getNsoFriendCodeUrl: (token: string) => inv<FriendCodeUrl>('coral:friendcodeurl', token),
getNsoUserByFriendCode: (token: string, friendcode: string, hash?: string) => inv<FriendCodeUser>('coral:friendcode', token, friendcode, hash),
addNsoFriend: (token: string, nsa_id: string) => inv<{result: CoralSuccessResponse<{}>; friend: Friend | null}>('coral:addfriend', token, nsa_id),
getDiscordPresenceConfig: () => inv<DiscordPresenceConfiguration | null>('discord:config'),
setDiscordPresenceConfig: (config: DiscordPresenceConfiguration | null) => inv<void>('discord:setconfig', config),
@ -55,6 +59,7 @@ const ipc = {
showFriendModal: (props: FriendProps) => inv<number>('window:showfriend', props),
showDiscordModal: (props: DiscordSetupProps = {}) => inv<number>('window:discord', props),
showAddFriendModal: (props: AddFriendProps) => inv<number>('window:addfriend', props),
setWindowHeight: (height: number) => inv('window:setheight', height),
openExternalUrl: (url: string) => inv('misc:open-url', url),

View File

@ -2,7 +2,7 @@ import createDebug from 'debug';
import * as persist from 'node-persist';
import CoralApi from '../api/coral.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { Announcements, Friends, GetActiveEventResult, WebServices, CoralSuccessResponse } from '../api/coral-types.js';
import { Announcements, Friends, GetActiveEventResult, WebServices, CoralSuccessResponse, Friend } from '../api/coral-types.js';
import { getToken, SavedToken } from './auth/coral.js';
const debug = createDebug('nxapi:users');
@ -136,4 +136,32 @@ export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T
return this.active_event.result;
}
async addFriend(nsa_id: string) {
if (nsa_id === this.data.nsoAccount.user.nsaId) {
throw new Error('Cannot add self as a friend');
}
const result = await this.nso.sendFriendRequest(nsa_id);
// Check if the user is now friends
// The Nintendo Switch Online app doesn't do this, but if the other user already sent a friend request to
// this user, they will be added as friends immediately. If the user is now friends we can show a message
// saying that, instead of saying that a friend request was sent when the user actually just accepted the
// other user's friend request.
let friend: Friend | null = null;
try {
// Clear the last updated timestamp to force updating the friend list
this.updated.friends = 0;
const friends = await this.getFriends();
friend = friends.find(f => f.nsaId === nsa_id) ?? null;
} catch (err) {
debug('Error updating friend list for %s to check if a friend request was accepted',
this.data.nsoAccount.user.name, err);
}
return {result, friend};
}
}