mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-21 22:47:46 -05:00
Electron app
This commit is contained in:
parent
60c0e592db
commit
065e9f6175
|
|
@ -39,6 +39,7 @@ const cli = {
|
|||
}),
|
||||
json(),
|
||||
nodeResolve({
|
||||
exportConditions: ['node'],
|
||||
browser: false,
|
||||
preferBuiltins: true,
|
||||
}),
|
||||
|
|
@ -59,6 +60,7 @@ const app = {
|
|||
file: 'dist/bundle/app-main-bundle.cjs',
|
||||
format: 'cjs',
|
||||
inlineDynamicImports: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
typescript({
|
||||
|
|
@ -74,6 +76,7 @@ const app = {
|
|||
}),
|
||||
json(),
|
||||
nodeResolve({
|
||||
exportConditions: ['node'],
|
||||
browser: false,
|
||||
preferBuiltins: true,
|
||||
}),
|
||||
|
|
@ -92,6 +95,7 @@ const app_preload = {
|
|||
output: {
|
||||
file: 'dist/app/bundle/preload.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
typescript({
|
||||
|
|
@ -152,6 +156,7 @@ const app_browser = {
|
|||
output: {
|
||||
file: 'dist/app/bundle/browser.js',
|
||||
format: 'iife',
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
html({
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, useColorScheme, View } from 'react-native';
|
||||
import type { NintendoAccountUser } from '../../api/na.js';
|
||||
import type { SavedToken } from '../../common/auth/nso.js';
|
||||
import type { SavedMoonToken } from '../../common/auth/moon.js';
|
||||
import ipc from './ipc.js';
|
||||
import { Root, useAsync } from './util.js';
|
||||
import Sidebar from './main/sidebar.js';
|
||||
import Update from './main/update.js';
|
||||
import Main from './main/index.js';
|
||||
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from './constants.js';
|
||||
|
||||
export interface AppProps {
|
||||
//
|
||||
}
|
||||
|
||||
export interface User {
|
||||
user: NintendoAccountUser;
|
||||
nso: SavedToken | null;
|
||||
nsotoken: string | undefined;
|
||||
moon: SavedMoonToken | null;
|
||||
moontoken: string | undefined;
|
||||
}
|
||||
|
||||
async function getAccounts() {
|
||||
const ids = await ipc.listNintendoAccounts();
|
||||
|
||||
const accounts: User[] = [];
|
||||
|
||||
for (const id of ids ?? []) {
|
||||
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
|
||||
const moontoken = await ipc.getNintendoAccountMoonToken(id);
|
||||
|
||||
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
|
||||
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
|
||||
|
||||
if (!nso && !moon) continue;
|
||||
|
||||
accounts.push({user: nso?.user ?? moon!.user, nso, nsotoken, moon, moontoken});
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
function App(props: AppProps) {
|
||||
const colour_scheme = useColorScheme();
|
||||
const theme = colour_scheme === 'light' ? light : dark;
|
||||
|
||||
const [users] = useAsync(useCallback(() => getAccounts(), [ipc]));
|
||||
|
||||
console.log(users);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | undefined>(undefined);
|
||||
const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUser) setSelectedUserId(users?.[0]?.user.id);
|
||||
}, [users, selectedUser]);
|
||||
|
||||
return <Root titleUser={selectedUser} style={styles.app}>
|
||||
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId} />
|
||||
|
||||
<View style={[styles.main, theme.main]}>
|
||||
<ScrollView style={styles.scroller} contentContainerStyle={styles.scrollerContent}>
|
||||
<Update />
|
||||
|
||||
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser} /> : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Root>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
app: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
},
|
||||
scroller: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
main: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
|
||||
},
|
||||
text: {
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
main: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
|
||||
},
|
||||
text: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
});
|
||||
|
||||
export default App;
|
||||
|
|
@ -2,8 +2,11 @@ export const NSO_COLOUR = '#e60012';
|
|||
export const NSO_COLOUR_DARK: `${typeof NSO_COLOUR}e0` = `${NSO_COLOUR}e0`;
|
||||
export const DISCORD_COLOUR = '#5865f2';
|
||||
|
||||
export const BACKGROUND_COLOUR_MAIN_LIGHT = '#e7e7e7';
|
||||
export const BACKGROUND_COLOUR_MAIN_DARK = '#202020';
|
||||
export const BACKGROUND_COLOUR_MAIN_LIGHT = '#ececec';
|
||||
export const BACKGROUND_COLOUR_MAIN_DARK = '#252424';
|
||||
|
||||
export const BACKGROUND_COLOUR_SECONDARY_LIGHT = '#ffffff';
|
||||
export const BACKGROUND_COLOUR_SECONDARY_DARK = '#353535';
|
||||
|
||||
export const UPDATE_COLOUR = '#006064e0';
|
||||
|
||||
|
|
@ -16,3 +19,5 @@ export const BORDER_COLOUR_DARK = '#00000080';
|
|||
export const TEXT_COLOUR_LIGHT = '#212121';
|
||||
export const TEXT_COLOUR_DARK = '#f5f5f5';
|
||||
export const TEXT_COLOUR_ACTIVE = '#3ba55d';
|
||||
|
||||
export const DEFAULT_ACCENT_COLOUR = NSO_COLOUR.substr(1).toUpperCase() + 'FF';
|
||||
|
|
|
|||
234
src/app/browser/discord/index.tsx
Normal file
234
src/app/browser/discord/index.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
|
||||
import { Picker } from 'react-native-web';
|
||||
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, User } from '../util.js';
|
||||
|
||||
export interface DiscordSetupProps {
|
||||
//
|
||||
}
|
||||
|
||||
enum DiscordSourceType {
|
||||
ZNC,
|
||||
URL,
|
||||
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, []);
|
||||
|
||||
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
|
||||
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
|
||||
|
||||
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
|
||||
|
||||
const [selectedMode, setSelectedMode] = useState(DiscordSourceType.NONE);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedFriendNsaId, setSelectedFriendNsaId] = useState<string | null>(null);
|
||||
const [presenceUrl, setPresenceUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!discord_presence_source) {
|
||||
setSelectedMode(DiscordSourceType.NONE);
|
||||
} else if ('na_id' in discord_presence_source) {
|
||||
setSelectedMode(DiscordSourceType.ZNC);
|
||||
setSelectedUserId(discord_presence_source.na_id);
|
||||
setSelectedFriendNsaId(discord_presence_source.friend_nsa_id ?? null);
|
||||
} else if ('url' in discord_presence_source) {
|
||||
setSelectedMode(DiscordSourceType.URL);
|
||||
setPresenceUrl(discord_presence_source.url);
|
||||
}
|
||||
}, [discord_presence_source]);
|
||||
|
||||
const user = useMemo(() => selectedUserId ? users?.find(u => u.user.id === selectedUserId) : undefined,
|
||||
[selectedUserId, 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 ||
|
||||
f.nsaId === selectedFriendNsaId), [friends, selectedFriendNsaId]);
|
||||
const friend = useMemo(() => selectedFriendNsaId ? friends?.find(f => f.nsaId === selectedFriendNsaId) : undefined,
|
||||
[selectedFriendNsaId, friends]);
|
||||
|
||||
useEventListener(events, 'window:refresh', () => (forceRefreshAccounts(), forceRefreshFriends()), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (users?.length && !user) setSelectedUserId(users[0].user.id);
|
||||
}, [users, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (friends?.length && !friend) setSelectedFriendNsaId(friends[0].nsaId);
|
||||
}, [friends, user]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (selectedMode === DiscordSourceType.ZNC) {
|
||||
if (!selectedUserId?.match(/^[0-9a-f]{16}$/)) throw new Error('Invalid Nintendo Account ID');
|
||||
if (!selectedFriendNsaId?.match(/^[0-9a-f]{16}$/)) throw new Error('Invalid friend Network Service Account ID');
|
||||
|
||||
await ipc.setDiscordPresenceSource({na_id: selectedUserId, friend_nsa_id: selectedFriendNsaId});
|
||||
} else if (selectedMode === DiscordSourceType.URL) {
|
||||
await ipc.setDiscordPresenceSource({url: presenceUrl});
|
||||
} else {
|
||||
await ipc.setDiscordPresenceSource(null);
|
||||
}
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
if (!users || discord_presence_source_state !== RequestState.LOADED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Root title="Discord Rich Presence setup" scrollable autoresize>
|
||||
<View style={styles.main}>
|
||||
<Text style={theme.text}>Discord Rich Presence setup</Text>
|
||||
|
||||
<Text style={[styles.header, theme.text]}>1. Select mode</Text>
|
||||
|
||||
<Picker<DiscordSourceType> selectedValue={selectedMode} onValueChange={v => setSelectedMode(parseInt(v))}
|
||||
style={[styles.picker, theme.picker]}
|
||||
>
|
||||
<Picker.Item label="Select a user that is friends with the user you want to share"
|
||||
value={DiscordSourceType.ZNC} />
|
||||
<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} />
|
||||
</Picker>
|
||||
|
||||
{selectedMode === DiscordSourceType.ZNC ? <>
|
||||
<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>
|
||||
|
||||
<Picker<string> selectedValue={selectedUserId ?? ''} onValueChange={setSelectedUserId}
|
||||
style={[styles.picker, theme.picker]}
|
||||
>
|
||||
{users.filter(u => u.nso).map(u => <Picker.Item
|
||||
label={u.user.nickname +
|
||||
(u.user.nickname !== u.nso!.nsoAccount.user.name ? '/' + u.nso!.nsoAccount.user.name : '')}
|
||||
value={u.user.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 label={f.name} value={f.nsaId} />)}
|
||||
</Picker>
|
||||
</> : 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>
|
||||
|
||||
<TextInput value={presenceUrl} onChangeText={setPresenceUrl}
|
||||
placeholder="https://nxapi.example.com/api/znc/friend/..."
|
||||
style={[styles.textInput, theme.textInput]} />
|
||||
</> : 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}
|
||||
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Root>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
paddingVertical: 50,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
main: {
|
||||
flex: 1,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
header: {
|
||||
marginTop: 12,
|
||||
},
|
||||
help: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
picker: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderWidth: 0,
|
||||
borderRadius: 3,
|
||||
fontSize: 13,
|
||||
},
|
||||
textInput: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 3,
|
||||
fontSize: 13,
|
||||
},
|
||||
|
||||
buttons: {
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
button: {
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
picker: {
|
||||
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
picker: {
|
||||
backgroundColor: HIGHLIGHT_COLOUR_DARK,
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: HIGHLIGHT_COLOUR_DARK,
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
/// <reference path="../react-native-web.d.ts" />
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Button, Image, StyleSheet, Text, useColorScheme, View } from 'react-native';
|
||||
import { ActivityIndicator, Button, Image, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
|
||||
import { CheckBox } from 'react-native-web';
|
||||
import { Game, Presence, PresencePermissions, PresenceState } from '../../../api/znc-types.js';
|
||||
import { getTitleIdFromEcUrl, hrduration } from '../../../util/misc.js';
|
||||
import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import { DEFAULT_ACCENT_COLOUR, TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import ipc, { events } from '../ipc.js';
|
||||
import { RequestState, Root, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener } from '../util.js';
|
||||
|
||||
|
|
@ -29,14 +32,29 @@ export default function Friend(props: FriendProps) {
|
|||
|
||||
useEffect(() => {
|
||||
if (friends_state !== RequestState.LOADED) return;
|
||||
|
||||
const timeout = setTimeout(forceRefreshFriends, 60 * 1000);
|
||||
|
||||
const timeout = setTimeout(forceRefreshFriends, 40 * 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [ipc, token, friends_state]);
|
||||
|
||||
useEventListener(events, 'window:refresh', forceRefreshFriends, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
if (friends && !friend) throw new Error('Unknown friend');
|
||||
if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) return null;
|
||||
|
||||
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}
|
||||
>
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
|
||||
</View>
|
||||
</Root>;
|
||||
}
|
||||
|
||||
const discord_presence_active = discord_presence_source && 'na_id' in discord_presence_source &&
|
||||
discord_presence_source.na_id === user.user.id &&
|
||||
|
|
@ -44,7 +62,7 @@ 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} scrollable>
|
||||
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} />
|
||||
|
|
@ -60,24 +78,34 @@ export default function Friend(props: FriendProps) {
|
|||
{(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
|
||||
'name' in friend.presence.game ? <FriendPresenceGame game={friend.presence.game} /> : null}
|
||||
|
||||
<Text style={theme.text}>NSA ID: {friend.nsaId}</Text>
|
||||
<Text style={theme.text}>{friend.isServiceUser ? 'Coral user ID: ' + friend.id : 'Never used Nintendo Switch Online app'}</Text>
|
||||
<Text style={theme.text}>Friends since {new Date(friend.friendCreatedAt * 1000).toLocaleString('en-GB')}</Text>
|
||||
{friend.presence.updatedAt ? <Text style={theme.text}>Presence updated at {new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB')}</Text> : null}
|
||||
<Text style={theme.text}>This user {can_see_user_presence ? 'can' : 'can not'} see your presence.</Text>
|
||||
<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}
|
||||
<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 ? <Button title="Stop sharing presence to Discord"
|
||||
onPress={() => ipc.setDiscordPresenceSource(null)}
|
||||
color={'#' + accent_colour} /> :
|
||||
friend.presence.updatedAt ? <Button title="Share presence to Discord"
|
||||
onPress={() => ipc.setDiscordPresenceSource({na_id: user.user.id, friend_nsa_id: friend.nsaId})}
|
||||
color={'#' + accent_colour} /> : null}
|
||||
{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()}
|
||||
color={'#' + accent_colour} />
|
||||
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -124,8 +152,14 @@ function FriendPresenceGame(props: {
|
|||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
main: {
|
||||
loading: {
|
||||
flex: 1,
|
||||
paddingVertical: 50,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
main: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
flexDirection: 'row',
|
||||
|
|
@ -157,11 +191,51 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
|
||||
friendNsaId: {
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
friendNsaIdValue: {
|
||||
fontFamily: 'monospace',
|
||||
userSelect: 'all',
|
||||
},
|
||||
friendCoralId: {
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
friendCoralIdValue: {
|
||||
fontFamily: 'monospace',
|
||||
userSelect: 'all',
|
||||
},
|
||||
friendCreatedAt: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
presenceUpdatedAt: {
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
canSeeUserPresence: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
buttons: {
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
discord: {
|
||||
flex: 1,
|
||||
marginRight: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
discordCheckbox: {
|
||||
marginRight: 10,
|
||||
},
|
||||
|
||||
presenceText: {
|
||||
marginTop: 8,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import { AppRegistry } from 'react-native';
|
||||
import { config } from './ipc.js';
|
||||
import App from './app.jsx';
|
||||
import App from './main/index.js';
|
||||
import Friend from './friend/index.js';
|
||||
import DiscordSetup from './discord/index.js';
|
||||
|
||||
AppRegistry.registerComponent('App', () => App);
|
||||
AppRegistry.registerComponent('Friend', () => Friend);
|
||||
AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
|
||||
|
||||
const style = window.document.createElement('style');
|
||||
|
||||
style.textContent = `
|
||||
html {
|
||||
:root {
|
||||
user-select: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline-style: solid;
|
||||
outline-width: medium;
|
||||
}
|
||||
input,
|
||||
input:focus-visible {
|
||||
outline: none 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
112
src/app/browser/main/discord-setup.tsx
Normal file
112
src/app/browser/main/discord-setup.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Button, Image, StyleSheet, Text, View } from 'react-native';
|
||||
import ipc, { events } from '../ipc.js';
|
||||
import { getAccounts, RequestState, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener, User } from '../util.js';
|
||||
import { Friend, PresencePermissions } from '../../../api/znc-types.js';
|
||||
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import Section from './section.js';
|
||||
|
||||
export default function SetupDiscordPresence(props: {
|
||||
user: User;
|
||||
friends: Friend[] | null;
|
||||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
const accent_colour = useAccentColour();
|
||||
|
||||
const [source, discord_presence_source_state] = useDiscordPresenceSource();
|
||||
|
||||
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
|
||||
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
|
||||
|
||||
const added_friends = useMemo(() => users?.filter(u => u.nso && props.friends?.find(f => {
|
||||
if (f.nsaId !== u.nso?.nsoAccount.user.nsaId) return false;
|
||||
|
||||
return props.user.nso!.nsoAccount.user.permissions.presence === PresencePermissions.FRIENDS ||
|
||||
(props.user.nso!.nsoAccount.user.permissions.presence === PresencePermissions.FAVORITE_FRIENDS &&
|
||||
f.isFavoriteFriend);
|
||||
})), [users, props.friends]);
|
||||
|
||||
const auth_user = source && 'na_id' in source ? users?.find(u => u.user.id === source.na_id)?.nso : null;
|
||||
const friend = source && 'na_id' in source && source.na_id === props.user.user.id && source.friend_nsa_id ?
|
||||
props.friends?.find(f => f.nsaId === source.friend_nsa_id) : null;
|
||||
|
||||
if (!props.friends || discord_presence_source_state !== RequestState.LOADED || !users) return null;
|
||||
|
||||
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>)}.
|
||||
</Text>
|
||||
</> :!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>
|
||||
</> : 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>
|
||||
</> : 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.
|
||||
</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>
|
||||
</> : 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 + ')' : ''}.
|
||||
</Text>
|
||||
</> : null;
|
||||
|
||||
return content ? <Section title="Discord Rich Presence">
|
||||
<View style={styles.content}>
|
||||
{content}
|
||||
|
||||
{source ? <View style={styles.button}>
|
||||
<Button title="Disable" onPress={() => ipc.setDiscordPresenceSource(null)} color={'#' + accent_colour} />
|
||||
</View> : null}
|
||||
</View>
|
||||
</Section> : null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
marginTop: -4,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
text: {
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
},
|
||||
discordNsoUserImage: {
|
||||
borderRadius: 8,
|
||||
textAlignVertical: -3,
|
||||
},
|
||||
|
||||
button: {
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Image, StyleSheet, Text, View } from 'react-native';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { User } from 'discord-rpc';
|
||||
import ipc from '../ipc.js';
|
||||
import { RequestState, useAsync } from '../util.js';
|
||||
import ipc, { events } from '../ipc.js';
|
||||
import { RequestState, useAsync, useEventListener } from '../util.js';
|
||||
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceZnc } from '../../common/types.js';
|
||||
import { DiscordPresence } from '../../../discord/util.js';
|
||||
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
|
||||
|
|
@ -14,10 +14,12 @@ export default function DiscordPresenceSource(props: {
|
|||
}) {
|
||||
if (!props.source) return null;
|
||||
|
||||
return <View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
|
||||
{renderDiscordPresenceSource(props.source)}
|
||||
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
|
||||
</View>;
|
||||
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
|
||||
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
|
||||
{renderDiscordPresenceSource(props.source)}
|
||||
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
|
||||
</View>
|
||||
</TouchableOpacity>;
|
||||
}
|
||||
|
||||
function renderDiscordPresenceSource(source: DiscordPresenceSource | null) {
|
||||
|
|
@ -35,20 +37,18 @@ function DiscordPresenceSourceZnc(props: {
|
|||
}) {
|
||||
const [token] = useAsync(useCallback(() =>
|
||||
ipc.getNintendoAccountNsoToken(props.source.na_id), [ipc, props.source.na_id]));
|
||||
const [user] = useAsync(useCallback(() => token ?
|
||||
ipc.getSavedNsoToken(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.source.friend_nsa_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (friends_state !== RequestState.LOADED) return;
|
||||
|
||||
const timeout = setTimeout(forceRefreshFriends, 60 * 1000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [ipc, token, friends_state]);
|
||||
|
||||
useEventListener(events, 'window:refresh', forceRefreshFriends, []);
|
||||
|
||||
return <View style={styles.discordSource}>
|
||||
{friend ? <Text style={styles.discordSourceText}>
|
||||
Discord Rich Presence active:{' '}
|
||||
|
|
@ -64,7 +64,10 @@ function DiscordPresenceSourceUrl(props: {
|
|||
source: DiscordPresenceSourceUrl;
|
||||
}) {
|
||||
return <View style={styles.discordSource}>
|
||||
<Text style={styles.discordSourceText}>Discord Rich Presence active: {props.source.url}</Text>
|
||||
<Text style={styles.discordSourceText} numberOfLines={3} ellipsizeMode="tail">
|
||||
Discord Rich Presence active:{' '}
|
||||
<Text style={styles.discordSourceUrlValue}>{props.source.url}</Text>
|
||||
</Text>
|
||||
</View>;
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +90,12 @@ 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}>Playing</Text>
|
||||
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">Playing</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.discordUser}>
|
||||
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
|
||||
<Text style={styles.discordUserText}>Connected as {props.user.username}#{props.user.discriminator}</Text>
|
||||
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{props.user.username}#{props.user.discriminator}</Text>
|
||||
</View>
|
||||
</>;
|
||||
}
|
||||
|
|
@ -116,9 +119,14 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 8,
|
||||
textAlignVertical: -3,
|
||||
},
|
||||
discordSourceUrlValue: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
userSelect: 'all',
|
||||
},
|
||||
|
||||
discordPresence: {
|
||||
marginTop: 16,
|
||||
marginTop: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
|
@ -131,7 +139,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
|
||||
discordUser: {
|
||||
marginTop: 12,
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Button, Image, StyleSheet, Text, View } from 'react-native';
|
||||
import ipc from '../ipc.js';
|
||||
import { useAccentColour, useColourScheme } from '../util.js';
|
||||
import { User } from '../app.js';
|
||||
import { useAccentColour, useColourScheme, User } from '../util.js';
|
||||
import { ActiveEvent } from '../../../api/znc-types.js';
|
||||
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import Section from './section.js';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Image, ImageStyle, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Image, ImageStyle, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import ipc from '../ipc.js';
|
||||
import { useColourScheme } from '../util.js';
|
||||
import { User } from '../app.js';
|
||||
import { useColourScheme, User } from '../util.js';
|
||||
import { Friend, Presence, PresenceState } from '../../../api/znc-types.js';
|
||||
import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import Section from './section.js';
|
||||
|
|
@ -14,12 +13,20 @@ export default function Friends(props: {
|
|||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
|
||||
const fc = <Text style={styles.friendCodeValue}>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
|
||||
const onFriendCodeContextMenu = useCallback(() => {
|
||||
ipc.showFriendCodeMenu(props.user.nso!.nsoAccount.user.links.friendCode);
|
||||
}, [ipc, props.user.nso?.nsoAccount.user.links.friendCode]);
|
||||
|
||||
const fc = <Text
|
||||
style={styles.friendCodeValue}
|
||||
// @ts-expect-error react-native-web
|
||||
onContextMenu={onFriendCodeContextMenu}
|
||||
>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
|
||||
|
||||
return <Section title="Friends" loading={props.loading}>
|
||||
{props.friends.length ? <ScrollView horizontal>
|
||||
<View style={styles.content}>
|
||||
{props.friends.map(f => <Friend key={f.nsaId} friend={f} user={props.user.user.id} />)}
|
||||
{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>
|
||||
|
|
@ -34,10 +41,14 @@ export default function Friends(props: {
|
|||
|
||||
function Friend(props: {
|
||||
friend: Friend;
|
||||
user?: string;
|
||||
user?: User;
|
||||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
|
||||
const onContextMenu = useCallback(() => {
|
||||
ipc.showFriendMenu(props.user!.user, props.user!.nso!.nsoAccount.user, props.friend);
|
||||
}, [ipc, props.user?.user, props.user?.nso?.nsoAccount.user]);
|
||||
|
||||
const game = 'name' in props.friend.presence.game ? props.friend.presence.game : null;
|
||||
|
||||
const content = <View style={styles.friend}>
|
||||
|
|
@ -48,10 +59,17 @@ function Friend(props: {
|
|||
{props.friend.presence.updatedAt ? <FriendPresence presence={props.friend.presence} /> : null}
|
||||
</View>;
|
||||
|
||||
const touchable = props.user ? <TouchableOpacity onPress={() => ipc.showFriendModal(props.user!.user.id, props.friend.nsaId)}>
|
||||
{content}
|
||||
</TouchableOpacity> : content;
|
||||
|
||||
const contextmenu = Platform.OS === 'web' && props.user ? <View
|
||||
// @ts-expect-error react-native-web
|
||||
onContextMenu={onContextMenu}
|
||||
>{touchable}</View> : touchable;
|
||||
|
||||
return <View style={styles.friendContainer}>
|
||||
{props.user ? <TouchableOpacity onPress={() => ipc.showFriendModal(props.user!, props.friend.nsaId)}>
|
||||
{content}
|
||||
</TouchableOpacity> : content}
|
||||
{contextmenu}
|
||||
</View>;
|
||||
}
|
||||
|
||||
|
|
@ -60,10 +78,8 @@ function FriendPresence(props: {
|
|||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
|
||||
const game = 'name' in props.presence.game ? props.presence.game : null;
|
||||
|
||||
if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) {
|
||||
return <Text numberOfLines={2} ellipsizeMode="tail" style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing {game?.name}</Text>;
|
||||
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing</Text>;
|
||||
}
|
||||
|
||||
return <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Offline</Text>;
|
||||
|
|
|
|||
|
|
@ -1,126 +1,103 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
|
||||
import ipc from '../ipc.js';
|
||||
import { RequestState, useAccentColour, useAsync, useColourScheme } from '../util.js';
|
||||
import { User } from '../app.js';
|
||||
import Friends from './friends.js';
|
||||
import WebServices from './webservices.js';
|
||||
import Event from './event.js';
|
||||
import Section from './section.js';
|
||||
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, ScrollView, StyleSheet, useColorScheme, View } from 'react-native';
|
||||
import ipc, { events } from '../ipc.js';
|
||||
import { AccentColourContext, getAccounts, Root, useAsync, useEventListener } from '../util.js';
|
||||
import Sidebar from './sidebar.js';
|
||||
import Update from './update.js';
|
||||
import Main from './main.js';
|
||||
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, BACKGROUND_COLOUR_SECONDARY_DARK, BACKGROUND_COLOUR_SECONDARY_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
|
||||
export default function Main(props: {
|
||||
user: User;
|
||||
}) {
|
||||
const accent_colour = useAccentColour();
|
||||
|
||||
const [announcements, , announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoFriends(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [webservices, , webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [active_event, , active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
|
||||
const loading = announcements_state === RequestState.LOADING ||
|
||||
friends_state === RequestState.LOADING ||
|
||||
webservices_state === RequestState.LOADING ||
|
||||
active_event_state === RequestState.LOADING;
|
||||
const refresh = useCallback(() => Promise.all([
|
||||
forceRefreshFriends(), forceRefreshWebServices(), forceRefreshActiveEvent(),
|
||||
]), [forceRefreshFriends, forceRefreshWebServices, forceRefreshActiveEvent]);
|
||||
|
||||
const [auto_refresh, setAutoRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !auto_refresh) return;
|
||||
|
||||
const timeout = setTimeout(refresh, 60 * 1000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [ipc, props.user.nsotoken, loading, auto_refresh]);
|
||||
|
||||
if (loading && (!announcements || !friends || !webservices || !active_event)) {
|
||||
return <View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color={'#' + accent_colour} />
|
||||
</View>;
|
||||
}
|
||||
|
||||
return <View>
|
||||
{!props.user.nso && props.user.moon ? <MoonOnlyUser /> : null}
|
||||
|
||||
{props.user.nso && friends ? <Friends user={props.user} friends={friends}
|
||||
loading={friends_state === RequestState.LOADING} /> : null}
|
||||
{props.user.nso && webservices ? <WebServices user={props.user} webservices={webservices}
|
||||
loading={webservices_state === RequestState.LOADING} /> : null}
|
||||
{props.user.nso && active_event && 'id' in active_event ? <Event user={props.user} event={active_event}
|
||||
loading={active_event_state === RequestState.LOADING} /> : null}
|
||||
|
||||
<View style={styles.container}>
|
||||
<Button title="Refresh"
|
||||
onPress={refresh}
|
||||
color={'#' + accent_colour} />
|
||||
|
||||
<Button title={auto_refresh ? 'Disable auto refresh' : 'Enable auto refresh'}
|
||||
onPress={() => setAutoRefresh(!auto_refresh)}
|
||||
color={'#' + accent_colour} />
|
||||
</View>
|
||||
</View>;
|
||||
export interface AppProps {
|
||||
vibrancy?: boolean;
|
||||
insetTitleBarControls?: boolean;
|
||||
}
|
||||
|
||||
function MoonOnlyUser() {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
const accent_colour = useAccentColour();
|
||||
export default function App(props: AppProps) {
|
||||
const colour_scheme = useColorScheme();
|
||||
const theme = colour_scheme === 'light' ? light : dark;
|
||||
|
||||
return <Section title="Nintendo Switch Online">
|
||||
<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>
|
||||
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
|
||||
|
||||
<View style={styles.moonOnlyUserButton}>
|
||||
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
|
||||
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
|
||||
useEventListener(events, 'window:refresh', forceRefreshAccounts, []);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | undefined>(undefined);
|
||||
const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]);
|
||||
|
||||
const [auto_refresh, setAutoRefresh] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUser) setSelectedUserId(users?.[0]?.user.id);
|
||||
}, [users, selectedUser]);
|
||||
|
||||
return <Root
|
||||
titleUser={selectedUser}
|
||||
vibrancy={props.vibrancy}
|
||||
style={[styles.app, !props.vibrancy ? theme.appNoVibrancy : null]}
|
||||
>
|
||||
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId}
|
||||
insetTitleBarControls={props.insetTitleBarControls}
|
||||
>
|
||||
<View style={[styles.content, styles.button]}>
|
||||
<AccentColourContext.Consumer children={accent_colour => (
|
||||
<Button title={auto_refresh ? 'Disable auto refresh' : 'Enable auto refresh'}
|
||||
onPress={() => setAutoRefresh(auto_refresh ? undefined : 30 * 1000)}
|
||||
color={'#' + accent_colour} />
|
||||
)} />
|
||||
</View>
|
||||
</Sidebar>
|
||||
|
||||
<View style={[styles.main, theme.main]}>
|
||||
<ScrollView style={styles.scroller} contentContainerStyle={styles.scrollerContent}>
|
||||
<Update />
|
||||
|
||||
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser} /> : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Section>;
|
||||
</Root>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
container: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
moonOnlyUser: {
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
moonOnlyUserText: {
|
||||
marginBottom: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
moonOnlyUserButton: {
|
||||
marginTop: 10,
|
||||
app: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
},
|
||||
scroller: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
appNoVibrancy: {
|
||||
backgroundColor: BACKGROUND_COLOUR_SECONDARY_LIGHT,
|
||||
},
|
||||
main: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
|
||||
},
|
||||
text: {
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
appNoVibrancy: {
|
||||
backgroundColor: BACKGROUND_COLOUR_SECONDARY_DARK,
|
||||
},
|
||||
main: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
|
||||
},
|
||||
text: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
|
|
|
|||
117
src/app/browser/main/main.tsx
Normal file
117
src/app/browser/main/main.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
|
||||
import ipc, { events } from '../ipc.js';
|
||||
import { RequestState, useAccentColour, useAsync, useColourScheme, useEventListener, User } from '../util.js';
|
||||
import Friends from './friends.js';
|
||||
import WebServices from './webservices.js';
|
||||
import Event from './event.js';
|
||||
import Section from './section.js';
|
||||
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import SetupDiscordPresence from './discord-setup.js';
|
||||
|
||||
export default function Main(props: {
|
||||
user: User;
|
||||
autoRefresh?: number;
|
||||
}) {
|
||||
const accent_colour = useAccentColour();
|
||||
|
||||
const [announcements, , announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoFriends(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [webservices, , webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
const [active_event, , active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
|
||||
ipc.getNsoActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
|
||||
|
||||
const loading = announcements_state === RequestState.LOADING ||
|
||||
friends_state === RequestState.LOADING ||
|
||||
webservices_state === RequestState.LOADING ||
|
||||
active_event_state === RequestState.LOADING;
|
||||
const refresh = useCallback(() => Promise.all([
|
||||
forceRefreshFriends(), forceRefreshWebServices(), forceRefreshActiveEvent(),
|
||||
]), [forceRefreshFriends, forceRefreshWebServices, forceRefreshActiveEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !props.autoRefresh) return;
|
||||
const timeout = setTimeout(refresh, props.autoRefresh);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [ipc, props.user.nsotoken, loading, props.autoRefresh]);
|
||||
|
||||
useEventListener(events, 'window:refresh', refresh, []);
|
||||
|
||||
if (loading && (!announcements || !friends || !webservices || !active_event)) {
|
||||
return <View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color={'#' + accent_colour} />
|
||||
</View>;
|
||||
}
|
||||
|
||||
return <View>
|
||||
{!props.user.nso && props.user.moon ? <MoonOnlyUser /> : null}
|
||||
|
||||
{props.user.nso ? <SetupDiscordPresence user={props.user} friends={friends} /> : null}
|
||||
{props.user.nso && friends ? <Friends user={props.user} friends={friends}
|
||||
loading={friends_state === RequestState.LOADING} /> : null}
|
||||
{props.user.nso && webservices ? <WebServices user={props.user} webservices={webservices}
|
||||
loading={webservices_state === RequestState.LOADING} /> : null}
|
||||
{props.user.nso && active_event && 'id' in active_event ? <Event user={props.user} event={active_event}
|
||||
loading={active_event_state === RequestState.LOADING} /> : null}
|
||||
</View>;
|
||||
}
|
||||
|
||||
function MoonOnlyUser() {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
const accent_colour = useAccentColour();
|
||||
|
||||
return <Section title="Nintendo Switch Online">
|
||||
<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>
|
||||
|
||||
<View style={styles.moonOnlyUserButton}>
|
||||
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
|
||||
</View>
|
||||
</View>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
container: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
moonOnlyUser: {
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
moonOnlyUserText: {
|
||||
marginBottom: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
moonOnlyUserButton: {
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
text: {
|
||||
color: TEXT_COLOUR_DARK,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Button, Image, ImageURISource, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Image, ImageURISource, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import ipc from '../ipc.js';
|
||||
import { RequestState, useAccentColour, useActiveDiscordPresence, useActiveDiscordUser, useAsync, useColourScheme, useDiscordPresenceSource } from '../util.js';
|
||||
import { User } from '../app.js';
|
||||
import { RequestState, useActiveDiscordPresence, useActiveDiscordUser, useColourScheme, useDiscordPresenceSource, User } from '../util.js';
|
||||
import DiscordPresenceSource from './discord.js';
|
||||
import { BORDER_COLOUR_DARK, BORDER_COLOUR_LIGHT, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, NSO_COLOUR, NSO_COLOUR_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
|
||||
|
|
@ -10,16 +9,17 @@ export default function Sidebar(props: {
|
|||
users?: User[] | null;
|
||||
selectedUser?: string;
|
||||
onSelectUser?: (na_id: string) => void;
|
||||
insetTitleBarControls?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
const accent_colour = useAccentColour();
|
||||
|
||||
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
|
||||
const presence = useActiveDiscordPresence();
|
||||
const discord_user = useActiveDiscordUser();
|
||||
|
||||
return <View style={[styles.sidebar, theme.sidebar]}>
|
||||
<View style={[styles.top, theme.top]}>
|
||||
<View style={[styles.top, theme.top, props.insetTitleBarControls ? styles.insetTitleBarControls : null]}>
|
||||
<Text style={styles.topText}>nxapi</Text>
|
||||
<Text style={styles.topText}>Nintendo Switch Online</Text>
|
||||
</View>
|
||||
|
|
@ -39,19 +39,17 @@ export default function Sidebar(props: {
|
|||
/>)}
|
||||
</View> : null}
|
||||
|
||||
{props.users ? <View style={styles.addUser}>
|
||||
{props.users ? <TouchableOpacity onPress={() => ipc.showAddUserMenu()} style={styles.addUser}>
|
||||
<Text style={theme.text}>Add user</Text>
|
||||
<View style={styles.addUserButton}>
|
||||
<Button title="Add Nintendo Switch Online account"
|
||||
onPress={() => ipc.addNsoAccount().then(id => props.onSelectUser?.call(null, id))}
|
||||
color={'#' + accent_colour} />
|
||||
</View>
|
||||
<View style={styles.addUserButton}>
|
||||
<Button title="Add Nintendo Switch Parental Controls account"
|
||||
onPress={() => ipc.addMoonAccount().then(id => props.onSelectUser?.call(null, id))}
|
||||
color={'#' + accent_colour} />
|
||||
</View>
|
||||
</View> : null}
|
||||
</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>
|
||||
</TouchableOpacity> : null}
|
||||
|
||||
{props.children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>;
|
||||
|
|
@ -64,6 +62,10 @@ function User(props: {
|
|||
}) {
|
||||
const theme = useColourScheme() === 'light' ? light : dark;
|
||||
|
||||
const onContextMenu = useCallback(() => {
|
||||
ipc.showUserMenu(props.user.user, props.user.nso?.nsoAccount.user);
|
||||
}, [ipc, props.user.user, props.user.nso?.nsoAccount.user]);
|
||||
|
||||
const miiImageSource: ImageURISource = props.user.user.mii ? {
|
||||
uri: 'https://' + props.user.user.mii.imageOrigin + '/2.0.0/mii_images/' +
|
||||
props.user.user.mii.id + '/' +
|
||||
|
|
@ -77,7 +79,7 @@ function User(props: {
|
|||
height: 32,
|
||||
};
|
||||
|
||||
return <TouchableOpacity onPress={props.onPress}>
|
||||
const touchable = <TouchableOpacity onPress={props.onPress}>
|
||||
<View style={[styles.user, props.selected ? theme.userSelected : null]}>
|
||||
<View style={styles.userMii}>
|
||||
<Image source={miiImageSource} style={styles.userMiiImage} />
|
||||
|
|
@ -96,6 +98,11 @@ function User(props: {
|
|||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>;
|
||||
|
||||
return Platform.OS === 'web' ? <View
|
||||
// @ts-expect-error react-native-web
|
||||
onContextMenu={onContextMenu}
|
||||
>{touchable}</View> : touchable;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
@ -110,6 +117,9 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 28,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
insetTitleBarControls: {
|
||||
paddingTop: 58,
|
||||
},
|
||||
topText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
|
|
@ -128,13 +138,20 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
|
||||
addUser: {
|
||||
paddingVertical: 16,
|
||||
marginTop: 10,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
addUserButton: {
|
||||
marginTop: 5,
|
||||
},
|
||||
|
||||
discordSetup: {
|
||||
marginTop: 10,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
user: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 8,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import ipc from '../ipc.js';
|
||||
import { useColourScheme } from '../util.js';
|
||||
import { User } from '../app.js';
|
||||
import { useColourScheme, User } from '../util.js';
|
||||
import { WebService } from '../../../api/znc-types.js';
|
||||
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
|
||||
import Section from './section.js';
|
||||
|
|
|
|||
89
src/app/browser/react-native-web.d.ts
vendored
Normal file
89
src/app/browser/react-native-web.d.ts
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
declare module 'react-native-web' {
|
||||
export {};
|
||||
|
||||
import React from 'react';
|
||||
import { ViewProps, ViewStyle } from 'react-native';
|
||||
|
||||
export interface CheckBoxProps extends ViewProps {
|
||||
/**
|
||||
* Set the background color of the checkbox.
|
||||
* @default "#AAB8C2"
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Prevent all interactions with the checkbox.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Called when the state of the native checkbox changes.
|
||||
*/
|
||||
onChange?: (e: ChangeEvent) => void;
|
||||
/**
|
||||
* Called when the state of the native checkbox changes.
|
||||
*/
|
||||
onValueChange?: (value: boolean | 'mixed') => void;
|
||||
/**
|
||||
* Set the value of the checkbox.
|
||||
* @default false
|
||||
*/
|
||||
value?: boolean | 'mixed';
|
||||
}
|
||||
|
||||
export class CheckBox extends React.Component<CheckBoxProps> {}
|
||||
|
||||
export interface PickerItemProps<T = number | string> {
|
||||
/**
|
||||
* Color of the item label. (Limited by browser support.)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Text to display for this item.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Used to locate this view in end-to-end tests.
|
||||
*/
|
||||
testID?: string;
|
||||
/**
|
||||
* The value to be passed to the picker’s onValueChange callback when this item is selected.
|
||||
*/
|
||||
value?: T;
|
||||
}
|
||||
|
||||
declare class PickerItem<T = number | string> extends React.Component<PickerItemProps<T>> {}
|
||||
|
||||
export interface PickerStyle extends ViewStyle {
|
||||
color?: string;
|
||||
}
|
||||
export interface PickerProps<T = number | string> extends ViewProps {
|
||||
/**
|
||||
* The items to display in the picker must be of type Picker.Item.
|
||||
*/
|
||||
children?: React.ReactElement<PickerItemProps<T>, PickerItem> |
|
||||
React.ReactElement<PickerItemProps<T>, PickerItem>[];
|
||||
/**
|
||||
* Determines if the picker will be disabled, i.e., the user will not be able to make a selection.
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Callback for when an item is selected. This is called with the value and index prop of the item that was selected.
|
||||
*/
|
||||
onValueChange?: (value: string, index: number) => void;
|
||||
/**
|
||||
* Select the item with the matching value.
|
||||
*/
|
||||
selectedValue?: T;
|
||||
/**
|
||||
* Supported style properties.
|
||||
*/
|
||||
style?: StyleProp<PickerStyle>;
|
||||
}
|
||||
|
||||
export class Picker<T = number | string> extends React.Component<PickerProps<T>> {
|
||||
static Item = PickerItem<T>;
|
||||
}
|
||||
export namespace Picker {
|
||||
type Item<T = number | string> = PickerItem<T>;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,25 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import React, { useEffect } from 'react';
|
||||
import { ColorSchemeName, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { ColorSchemeName, LayoutChangeEvent, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native';
|
||||
import type { User as DiscordUser } from 'discord-rpc';
|
||||
import { ErrorResponse } from '../../api/util.js';
|
||||
import { DiscordPresence } from '../../discord/util.js';
|
||||
import ipc, { events } from './ipc.js';
|
||||
import { User } from './app.js';
|
||||
import { NintendoAccountUser } from '../../api/na.js';
|
||||
import { SavedToken } from '../../common/auth/nso.js';
|
||||
import { SavedMoonToken } from '../../common/auth/moon.js';
|
||||
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js';
|
||||
|
||||
export function Root(props: React.PropsWithChildren<{
|
||||
title?: string;
|
||||
titleUser?: User | SavedToken;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
scrollable?: boolean;
|
||||
autoresize?: boolean;
|
||||
vibrancy?: boolean;
|
||||
}>) {
|
||||
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, []);
|
||||
|
|
@ -30,10 +35,37 @@ export function Root(props: React.PropsWithChildren<{
|
|||
document.title = user_prefix + (props.title ?? 'Nintendo Switch Online');
|
||||
}, [props.title, props.titleUser]);
|
||||
|
||||
const [preventingFocus, setPreventFocus] = useState(true);
|
||||
const unlockFocus = useCallback(() => setPreventFocus(false), []);
|
||||
useLayoutEffect(() => setPreventFocus(props.autoresize ?? true), [props.autoresize]);
|
||||
|
||||
const onLayout = useCallback(async (event: LayoutChangeEvent) => {
|
||||
await ipc.setWindowHeight(event.nativeEvent.layout.height);
|
||||
setPreventFocus(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.overflowY = props.scrollable ? 'auto' : 'hidden';
|
||||
}, [props.scrollable]);
|
||||
|
||||
return <ColourSchemeContext.Provider value={colour_scheme}>
|
||||
<AccentColourContext.Provider value={accent_colour}>
|
||||
<View style={[props.scrollable ? styles.appScrollable : styles.app, props.style]}>
|
||||
{props.children}
|
||||
<AccentColourContext.Provider value={accent_colour ?? DEFAULT_ACCENT_COLOUR}>
|
||||
<View style={[
|
||||
props.scrollable ? styles.appScrollable : styles.app,
|
||||
!props.vibrancy ? theme.appNoVibrancy : null,
|
||||
props.style,
|
||||
]}>
|
||||
{props.autoresize && preventingFocus ? <View
|
||||
key={'focuslock'}
|
||||
focusable
|
||||
// @ts-expect-error react-native-web
|
||||
onFocus={unlockFocus}
|
||||
/> : null}
|
||||
|
||||
{props.autoresize ? <View
|
||||
key={'autoresize'}
|
||||
onLayout={props.autoresize ? onLayout : undefined}
|
||||
>{props.children}</View> : props.children}
|
||||
</View>
|
||||
</AccentColourContext.Provider>
|
||||
</ColourSchemeContext.Provider>;
|
||||
|
|
@ -48,6 +80,18 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
const light = StyleSheet.create({
|
||||
appNoVibrancy: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
const dark = StyleSheet.create({
|
||||
appNoVibrancy: {
|
||||
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
|
||||
},
|
||||
});
|
||||
|
||||
export enum RequestState {
|
||||
NOT_LOADING,
|
||||
LOADING,
|
||||
|
|
@ -120,7 +164,7 @@ export function useEventListener<
|
|||
}, deps);
|
||||
}
|
||||
|
||||
export const AccentColourContext = React.createContext<string | null>('E60012FF');
|
||||
export const AccentColourContext = React.createContext<string>(DEFAULT_ACCENT_COLOUR);
|
||||
|
||||
export function useAccentColour() {
|
||||
return React.useContext(AccentColourContext);
|
||||
|
|
@ -132,6 +176,34 @@ export function useColourScheme() {
|
|||
return React.useContext(ColourSchemeContext);
|
||||
}
|
||||
|
||||
export interface User {
|
||||
user: NintendoAccountUser;
|
||||
nso: SavedToken | null;
|
||||
nsotoken: string | undefined;
|
||||
moon: SavedMoonToken | null;
|
||||
moontoken: string | undefined;
|
||||
}
|
||||
|
||||
export async function getAccounts() {
|
||||
const ids = await ipc.listNintendoAccounts();
|
||||
|
||||
const accounts: User[] = [];
|
||||
|
||||
for (const id of ids ?? []) {
|
||||
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
|
||||
const moontoken = await ipc.getNintendoAccountMoonToken(id);
|
||||
|
||||
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
|
||||
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
|
||||
|
||||
if (!nso && !moon) continue;
|
||||
|
||||
accounts.push({user: nso?.user ?? moon!.user, nso, nsotoken, moon, moontoken});
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export function useDiscordPresenceSource() {
|
||||
const [source, , state, forceRefresh] = useAsync(React.useCallback(() => ipc.getDiscordPresenceSource(), [ipc]));
|
||||
useEventListener(events, 'update-discord-presence-source', forceRefresh, []);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
export enum WindowType {
|
||||
MAIN_WINDOW = 'App',
|
||||
FRIEND = 'Friend',
|
||||
DISCORD_PRESENCE = 'DiscordPresence',
|
||||
}
|
||||
|
||||
interface WindowProps {
|
||||
[WindowType.MAIN_WINDOW]: import('../browser/app.js').AppProps;
|
||||
[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;
|
||||
}
|
||||
|
||||
export interface WindowConfiguration<T extends WindowType = WindowType> {
|
||||
|
|
|
|||
92
src/app/main/app-menu.tsx
Normal file
92
src/app/main/app-menu.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
|
||||
import { BrowserWindow, Menu, MenuItem, shell } from './electron.js';
|
||||
|
||||
const menu_app = new MenuItem({role: 'appMenu'});
|
||||
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_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',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
click: (menuItem, browserWindow, event) => {
|
||||
if (browserWindow === window) {
|
||||
browserWindow.webContents.send('nxapi:window:refresh');
|
||||
} else {
|
||||
browserWindow?.webContents.reload();
|
||||
}
|
||||
},
|
||||
accelerator: 'CommandOrControl+R',
|
||||
},
|
||||
{ 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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ const electron = require('electron');
|
|||
|
||||
export const app = electron.app;
|
||||
export const BrowserWindow = electron.BrowserWindow;
|
||||
export const clipboard = electron.clipboard;
|
||||
export const dialog = electron.dialog;
|
||||
export const ipcMain = electron.ipcMain;
|
||||
export const Menu = electron.Menu;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ 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 { version } from '../../util/product.js';
|
||||
import { GITLAB_URL } from '../../common/constants.js';
|
||||
|
||||
const debug = createDebug('app:main');
|
||||
|
||||
|
|
@ -38,10 +40,14 @@ export class App {
|
|||
return this.main_window;
|
||||
}
|
||||
|
||||
const window = createWindow(WindowType.MAIN_WINDOW, {}, {
|
||||
const window = createWindow(WindowType.MAIN_WINDOW, {
|
||||
vibrancy: process.platform === 'darwin',
|
||||
// insetTitleBarControls: process.platform === 'darwin',
|
||||
}, {
|
||||
minWidth: 500,
|
||||
minHeight: 300,
|
||||
vibrancy: 'under-window',
|
||||
vibrancy: process.platform === 'darwin' ? 'under-window' : undefined,
|
||||
// titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
webPreferences: {
|
||||
scrollBounce: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { BrowserWindow, dialog, IpcMain, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
|
||||
import { BrowserWindow, clipboard, dialog, IpcMain, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
|
||||
import * as util from 'node:util';
|
||||
import createDebug from 'debug';
|
||||
import openWebService, { WebServiceIpc } from './webservices.js';
|
||||
import { createWindow, getWindowConfiguration } from './windows.js';
|
||||
import { DiscordPresenceSource, WindowType } from '../common/types.js';
|
||||
import { WebService } from '../../api/znc-types.js';
|
||||
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/znc-types.js';
|
||||
import { addNsoAccount, addPctlAccount } from './na-auth.js';
|
||||
import { App } from './index.js';
|
||||
import { DiscordPresence } from '../../discord/util.js';
|
||||
import { User } from 'discord-rpc';
|
||||
import { NintendoAccountUser } from '../../api/na.js';
|
||||
import { hrduration } from '../../util/misc.js';
|
||||
|
||||
const debug = createDebug('app:main:ipc');
|
||||
|
||||
|
|
@ -18,15 +20,15 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
|
|||
|
||||
ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender));
|
||||
|
||||
let accent_colour = systemPreferences.getAccentColor();
|
||||
let accent_colour = systemPreferences.getAccentColor?.() || undefined;
|
||||
|
||||
ipcMain.on('nxapi:systemPreferences:accent-colour', e => e.returnValue = accent_colour);
|
||||
systemPreferences.subscribeLocalNotification('NSSystemColorsDidChangeNotification', (event, userInfo, object) => {
|
||||
accent_colour = systemPreferences.getAccentColor();
|
||||
systemPreferences.subscribeLocalNotification?.('NSSystemColorsDidChangeNotification', (event, userInfo, object) => {
|
||||
accent_colour = systemPreferences.getAccentColor?.();
|
||||
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
|
||||
});
|
||||
systemPreferences.on('accent-color-changed', (event, new_colour) => {
|
||||
accent_colour = new_colour ?? systemPreferences.getAccentColor();
|
||||
accent_colour = new_colour ?? systemPreferences.getAccentColor?.();
|
||||
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
|
||||
});
|
||||
|
||||
|
|
@ -73,9 +75,38 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
|
|||
}, {
|
||||
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
|
||||
modal: true,
|
||||
show: false,
|
||||
width: 560,
|
||||
height: 300,
|
||||
minWidth: 450,
|
||||
maxWidth: 700,
|
||||
minHeight: 300,
|
||||
maxHeight: 300,
|
||||
}).id);
|
||||
ipcMain.handle('nxapi:window:discord', e => createWindow(WindowType.DISCORD_PRESENCE, {}, {
|
||||
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
|
||||
modal: true,
|
||||
show: 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();
|
||||
const [curContentWidth, curContentHeight] = window.getContentSize();
|
||||
const [minWidth, minHeight] = window.getMinimumSize();
|
||||
const [maxWidth, maxHeight] = window.getMaximumSize();
|
||||
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
|
||||
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
|
||||
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
|
||||
}
|
||||
window.setContentSize(curContentWidth, height);
|
||||
window.show();
|
||||
});
|
||||
|
||||
ipcMain.handle('nxapi:discord:source', () => appinstance.monitors.getDiscordPresenceSource());
|
||||
ipcMain.handle('nxapi:discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
|
||||
|
|
@ -89,6 +120,31 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
|
|||
ipcMain.handle('nxapi:misc:share', (e, item: SharingItem) =>
|
||||
new ShareMenu(item).popup({window: BrowserWindow.fromWebContents(e.sender)!}));
|
||||
|
||||
ipcMain.handle('nxapi:menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) => (Menu.buildFromTemplate([
|
||||
new MenuItem({label: 'Nintendo Account 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({type: 'separator'}),
|
||||
new MenuItem({label: 'Use the nxapi command to remove this user', enabled: false}),
|
||||
]).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: () => addNsoAccount(storage)}),
|
||||
new MenuItem({label: 'Add Nintendo Switch Parental Controls account', click: () => addPctlAccount(storage)}),
|
||||
]).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({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}),
|
||||
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
|
||||
ipcMain.handle('nxapi:menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
|
||||
(buildFriendMenu(appinstance, user, nso, friend)
|
||||
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
|
||||
|
||||
const webserviceipc = new WebServiceIpc(store);
|
||||
ipcMain.on('nxapi:webserviceapi:getWebServiceSync', e => e.returnValue = webserviceipc.getWebService(e));
|
||||
ipcMain.handle('nxapi:webserviceapi:invokeNativeShare', (e, data: string) => webserviceipc.invokeNativeShare(e, data));
|
||||
|
|
@ -108,3 +164,44 @@ function sendToAllWindows(channel: string, ...args: any[]) {
|
|||
window.webContents.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFriendMenu(app: App, user: NintendoAccountUser, nso: CurrentUser, friend: 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;
|
||||
|
||||
return Menu.buildFromTemplate([
|
||||
...(!friend.presence.updatedAt ? [
|
||||
] : friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING ? [
|
||||
new MenuItem({label: '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: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
|
||||
new MenuItem({type: 'separator'}),
|
||||
] : friend.presence.state === PresenceState.INACTIVE ? [
|
||||
new MenuItem({label: 'Offline (console online)', enabled: false}),
|
||||
...(friend.presence.logoutAt ? [
|
||||
new MenuItem({label: 'Logout time: ' + new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB'), enabled: false}),
|
||||
] : []),
|
||||
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), 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: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
|
||||
new MenuItem({type: 'separator'}),
|
||||
]),
|
||||
new MenuItem({label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active, click: () =>
|
||||
app.monitors.setDiscordPresenceSource(discord_presence_active ? null :
|
||||
{na_id: user.id, friend_nsa_id: friend.nsaId})}),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default class MenuApp {
|
|||
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},
|
||||
{type: 'separator'},
|
||||
{label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ export function getAuthUrl(client_id: string, scope: string | string[]) {
|
|||
};
|
||||
}
|
||||
|
||||
const css = `
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
let i = 0;
|
||||
|
||||
export function createAuthWindow() {
|
||||
|
|
@ -60,6 +66,10 @@ export function createAuthWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
window.webContents.insertCSS(css);
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { constants } from 'node:fs';
|
|||
import * as fs from 'node:fs/promises';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import createDebug from 'debug';
|
||||
import { app, BrowserWindow, dialog, IpcMainInvokeEvent, Menu, MenuItem, session, ShareMenu, shell, WebContents } from './electron.js';
|
||||
import { app, BrowserWindow, dialog, IpcMainInvokeEvent, Menu, MenuItem, ShareMenu, shell, WebContents } from './electron.js';
|
||||
import fetch from 'node-fetch';
|
||||
import ZncApi from '../../api/znc.js';
|
||||
import { dev } from '../../util/product.js';
|
||||
|
|
@ -11,32 +11,10 @@ import { WebService } from '../../api/znc-types.js';
|
|||
import { Store } from './index.js';
|
||||
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js';
|
||||
import { SavedToken } from '../../common/auth/nso.js';
|
||||
import { bundlepath } from './util.js';
|
||||
import Users from '../../common/users.js';
|
||||
import { createWebServiceWindow } from './windows.js';
|
||||
|
||||
const debug = createDebug('app:main:webservices');
|
||||
|
||||
export function createWebServiceWindow(nsa_id: string, webservice: WebService, title_prefix?: string) {
|
||||
const browser_session = session.fromPartition('persist:webservices-' + nsa_id, {
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 375,
|
||||
height: 667,
|
||||
resizable: false,
|
||||
title: (title_prefix ?? '') + webservice.name,
|
||||
webPreferences: {
|
||||
session: browser_session,
|
||||
preload: path.join(bundlepath, 'preload-webservice.cjs'),
|
||||
contextIsolation: false,
|
||||
scrollBounce: true,
|
||||
},
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
const windows = new Map<string, BrowserWindow>();
|
||||
const windowapi = new WeakMap<WebContents, [Store, string, ZncApi, SavedToken, WebService]>();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from './electron.js';
|
||||
import { app, BrowserWindow, BrowserWindowConstructorOptions, Menu, 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 { WebService } from '../../api/znc-types.js';
|
||||
|
||||
const windows = new Map<WebContents, WindowConfiguration>();
|
||||
const menus = new Map<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'],
|
||||
|
|
@ -15,6 +26,7 @@ export function createWindow<T extends WindowType>(
|
|||
width: 800,
|
||||
height: 600,
|
||||
vibrancy: 'content',
|
||||
autoHideMenuBar: true,
|
||||
...options,
|
||||
webPreferences: {
|
||||
preload: path.join(bundlepath, 'preload.cjs'),
|
||||
|
|
@ -29,6 +41,12 @@ export function createWindow<T extends WindowType>(
|
|||
};
|
||||
|
||||
windows.set(window.webContents, data);
|
||||
menus.set(window, createWindowMenu(window));
|
||||
|
||||
window.on('closed', () => {
|
||||
windows.delete(window.webContents);
|
||||
menus.delete(window);
|
||||
});
|
||||
|
||||
window.loadFile(path.join(bundlepath, 'index.html'));
|
||||
if (dev) window.webContents.openDevTools();
|
||||
|
|
@ -45,3 +63,30 @@ export function getWindowConfiguration(webcontents: WebContents): WindowConfigur
|
|||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createWebServiceWindow(nsa_id: string, webservice: WebService, title_prefix?: string) {
|
||||
const browser_session = session.fromPartition('persist:webservices-' + nsa_id, {
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const window = new BrowserWindow({
|
||||
width: 375,
|
||||
height: 667,
|
||||
resizable: false,
|
||||
title: (title_prefix ?? '') + webservice.name,
|
||||
webPreferences: {
|
||||
session: browser_session,
|
||||
preload: path.join(bundlepath, 'preload-webservice.cjs'),
|
||||
contextIsolation: false,
|
||||
scrollBounce: true,
|
||||
},
|
||||
});
|
||||
|
||||
menus.set(window, createWindowMenu(window));
|
||||
|
||||
window.on('closed', () => {
|
||||
menus.delete(window);
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,19 @@ import createDebug from 'debug';
|
|||
const debug = createDebug('app:preload-webservice');
|
||||
|
||||
import './znca-js-api.js';
|
||||
import './quirks/splatnet2.js';
|
||||
import './quirks/nooklink.js';
|
||||
|
||||
const style = window.document.createElement('style');
|
||||
|
||||
style.textContent = `
|
||||
*:focus-visible {
|
||||
outline-style: solid;
|
||||
outline-width: medium;
|
||||
}
|
||||
`;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
(document.scrollingElement as HTMLElement).style.overflowX = 'hidden';
|
||||
window.document.head.appendChild(style);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import createDebug from 'debug';
|
||||
import { WebServiceData } from '../main/webservices.js';
|
||||
|
||||
const debug = createDebug('app:preload-webservice:ipc');
|
||||
|
||||
export const events = new EventEmitter();
|
||||
|
||||
const ipc = {
|
||||
getWebServiceSync: () => ipcRenderer.sendSync('nxapi:webserviceapi:getWebServiceSync') as WebServiceData,
|
||||
invokeNativeShare: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShare', data) as Promise<void>,
|
||||
|
|
@ -19,3 +22,5 @@ export const {webservice, url: webserviceurl} = ipc.getWebServiceSync();
|
|||
|
||||
debug('Web service', webservice);
|
||||
debug('Web service URL', webserviceurl);
|
||||
|
||||
ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());
|
||||
|
|
|
|||
18
src/app/preload-webservice/quirks/splatnet2.ts
Normal file
18
src/app/preload-webservice/quirks/splatnet2.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import createDebug from 'debug';
|
||||
import { events, webservice } from '../ipc.js';
|
||||
|
||||
const debug = createDebug('app:preload-webservice:quirks:splatnet2');
|
||||
|
||||
const SPLATNET2_WEBSERVICE_ID = 5741031244955648;
|
||||
|
||||
if (webservice.id === SPLATNET2_WEBSERVICE_ID) {
|
||||
events.on('window:refresh', () => {
|
||||
const refresh_button = document.querySelector<HTMLElement>('.refresh-button');
|
||||
|
||||
if (refresh_button) {
|
||||
refresh_button.click();
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import * as process from 'node:process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import createDebug from 'debug';
|
||||
import type { DiscordPresenceSource, WindowConfiguration } from '../common/types.js';
|
||||
import type { SavedToken } from '../../common/auth/nso.js';
|
||||
import type { SavedMoonToken } from '../../common/auth/moon.js';
|
||||
import type { UpdateCacheData } from '../../common/update.js';
|
||||
import { Announcements, Friend, GetActiveEventResult, WebService, WebServices } from '../../api/znc-types.js';
|
||||
import { Announcements, CurrentUser, Friend, GetActiveEventResult, WebService, WebServices } from '../../api/znc-types.js';
|
||||
import { DiscordPresence } from '../../discord/util.js';
|
||||
import { User } from 'discord-rpc';
|
||||
import { SharingItem } from '../main/electron.js';
|
||||
import { NintendoAccountUser } from '../../api/na.js';
|
||||
|
||||
const debug = createDebug('app:preload');
|
||||
|
||||
|
|
@ -47,10 +49,17 @@ const ipc = {
|
|||
getSavedMoonToken: (token: string) => inv<SavedMoonToken | undefined>('moon:getcachedtoken', token),
|
||||
|
||||
showFriendModal: (na_id: string, nsa_id: string) => inv<number>('window:showfriend', na_id, nsa_id),
|
||||
showDiscordModal: () => inv<number>('window:discord'),
|
||||
setWindowHeight: (height: number) => inv('window:setheight', height),
|
||||
|
||||
openExternalUrl: (url: string) => inv('misc:open-url', url),
|
||||
share: (item: SharingItem) => inv('misc:share', item),
|
||||
|
||||
showUserMenu: (user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) => inv('menu:user', user, nso, moon),
|
||||
showAddUserMenu: () => inv('menu:add-user'),
|
||||
showFriendCodeMenu: (fc: CurrentUser['links']['friendCode']) => inv('menu:friend-code', fc),
|
||||
showFriendMenu: (user: NintendoAccountUser, nso: CurrentUser, friend: Friend) => inv('menu:friend', user, nso, friend),
|
||||
|
||||
registerEventListener: (event: string, listener: (args: any[]) => void) => events.on(event, listener),
|
||||
removeEventListener: (event: string, listener: (args: any[]) => void) => events.removeListener(event, listener),
|
||||
|
||||
|
|
@ -59,12 +68,13 @@ const ipc = {
|
|||
|
||||
export type NxapiElectronIpc = typeof ipc;
|
||||
|
||||
ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());
|
||||
ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintendo-accounts'));
|
||||
ipcRenderer.on('nxapi:discord:shouldrefresh', () => events.emit('update-discord-presence-source'));
|
||||
ipcRenderer.on('nxapi:discord:presence', (e, p: DiscordPresence) => events.emit('update-discord-presence', p));
|
||||
ipcRenderer.on('nxapi:discord:user', (e, u: User) => events.emit('update-discord-user', u));
|
||||
|
||||
let accent_colour: string = invSync('systemPreferences:accent-colour');
|
||||
let accent_colour: string | undefined = invSync('systemPreferences:accent-colour');
|
||||
ipcRenderer.on('nxapi:systemPreferences:accent-colour', (event, c) => {
|
||||
accent_colour = c;
|
||||
events.emit('systemPreferences:accent-colour', c);
|
||||
|
|
|
|||
3
src/common/constants.ts
Normal file
3
src/common/constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const GITLAB_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi';
|
||||
export const GITHUB_MIRROR_URL = 'https://github.com/samuelthomas2774/nxapi';
|
||||
export const ISSUES_URL = 'https://github.com/samuelthomas2774/nxapi/issues';
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"importHelpers": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"sourceMap": true,
|
||||
"lib": [
|
||||
"es2020"
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user