diff --git a/src/app/browser/components/icons/util.ts b/src/app/browser/components/icons/util.ts
new file mode 100644
index 0000000..4f00ee9
--- /dev/null
+++ b/src/app/browser/components/icons/util.ts
@@ -0,0 +1,7 @@
+import React from 'react';
+
+export const svg_styles: React.CSSProperties = {
+ height: '1em',
+ width: 'auto',
+ fill: 'currentColor',
+};
diff --git a/src/app/browser/components/icons/warning.tsx b/src/app/browser/components/icons/warning.tsx
new file mode 100644
index 0000000..d1a2e15
--- /dev/null
+++ b/src/app/browser/components/icons/warning.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { Platform, Text } from 'react-native';
+import { svg_styles } from './util.js';
+
+const IconWeb = React.memo(() =>
+
+);
+
+export default Platform.OS === 'web' ? IconWeb : React.memo(() => null);
diff --git a/src/app/browser/main/event.tsx b/src/app/browser/main/event.tsx
index e334ba4..f7c2727 100644
--- a/src/app/browser/main/event.tsx
+++ b/src/app/browser/main/event.tsx
@@ -11,6 +11,7 @@ export default function Event(props: {
user: User;
event: ActiveEvent;
loading?: boolean;
+ error?: Error;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
@@ -18,7 +19,7 @@ export default function Event(props: {
const event_members = props.event.members.filter(m => m.isPlaying).length;
const voip_members = props.event.members.filter(m => m.isJoinedVoip).length;
- return
+ return
diff --git a/src/app/browser/main/friends.tsx b/src/app/browser/main/friends.tsx
index 17ffbc9..ae84e91 100644
--- a/src/app/browser/main/friends.tsx
+++ b/src/app/browser/main/friends.tsx
@@ -10,6 +10,7 @@ export default function Friends(props: {
user: User;
friends: Friend[];
loading?: boolean;
+ error?: Error;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
@@ -23,7 +24,7 @@ export default function Friends(props: {
onContextMenu={onFriendCodeContextMenu}
>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id};
- return
+ return
{props.friends.length ?
{props.friends.map(f => )}
diff --git a/src/app/browser/main/main.tsx b/src/app/browser/main/main.tsx
index 547ed1d..d97b382 100644
--- a/src/app/browser/main/main.tsx
+++ b/src/app/browser/main/main.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
-import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, 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';
@@ -9,20 +9,22 @@ import Section from './section.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import SetupDiscordPresence from './discord-setup.js';
import { Button } from '../components/index.js';
+import { hrlist } from '../../../util/misc.js';
export default function Main(props: {
user: User;
autoRefresh?: number;
}) {
+ const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
- const [announcements, , announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
+ const [announcements, announcements_error, 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 ?
+ const [friends, friends_error, friends_state, forceRefreshFriends] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoFriends(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
- const [webservices, , webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
+ const [webservices, webservices_error, webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
- const [active_event, , active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
+ const [active_event, active_event_error, active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const loading = announcements_state === RequestState.LOADING ||
@@ -41,10 +43,37 @@ export default function Main(props: {
useEventListener(events, 'window:refresh', refresh, []);
- if (loading && (!announcements || !friends || !webservices || !active_event)) {
- return
-
- ;
+ const showErrorDetails = useCallback(() => {
+ if (friends_error) alert(friends_error.stack ?? friends_error.message);
+ if (webservices_error) alert(webservices_error.stack ?? webservices_error.message);
+ if (active_event_error) alert(active_event_error.stack ?? active_event_error.message);
+ }, [friends_error, webservices_error, active_event_error]);
+
+ if (!announcements || !friends || !webservices || !active_event) {
+ if (loading) {
+ return
+
+ ;
+ }
+
+ if (friends_error || webservices_error || active_event_error) {
+ const errors = [];
+ if (friends_error) errors.push('friends');
+ if (webservices_error) errors.push('game-specific services');
+ if (active_event_error) errors.push('voice chat');
+ const errors_text = hrlist(errors);
+
+ return
+ Error loading data
+ An error occured while loading {errors_text} data.
+
+
+
+ View details
+
+
+ ;
+ }
}
return
@@ -52,11 +81,11 @@ export default function Main(props: {
{props.user.nso ? : null}
{props.user.nso && friends ? : null}
+ loading={friends_state === RequestState.LOADING} error={friends_error ?? undefined} /> : null}
{props.user.nso && webservices ? : null}
+ loading={webservices_state === RequestState.LOADING} error={webservices_error ?? undefined} /> : null}
{props.user.nso && active_event && 'id' in active_event ? : null}
+ loading={active_event_state === RequestState.LOADING} error={active_event_error ?? undefined} /> : null}
;
}
@@ -103,6 +132,28 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'center',
},
+
+ error: {
+ flex: 1,
+ paddingVertical: 16,
+ paddingHorizontal: 20,
+ justifyContent: 'center',
+ },
+ errorHeader: {
+ marginBottom: 16,
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ errorMessage: {
+ marginBottom: 16,
+ textAlign: 'center',
+ },
+ errorActions: {
+ alignItems: 'center',
+ },
+ errorViewDetailsTouchable: {
+ marginTop: 10,
+ },
});
const light = StyleSheet.create({
diff --git a/src/app/browser/main/section.tsx b/src/app/browser/main/section.tsx
index acfc26d..7233a3c 100644
--- a/src/app/browser/main/section.tsx
+++ b/src/app/browser/main/section.tsx
@@ -1,27 +1,38 @@
-import React from 'react';
-import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import React, { useCallback } from 'react';
+import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useAccentColour, useColourScheme } from '../util.js';
import { BORDER_COLOUR_LIGHT, BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc from '../ipc.js';
+import Warning from '../components/icons/warning.js';
export default function Section(props: React.PropsWithChildren<{
title: string;
loading?: boolean;
+ error?: Error;
}>) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
+ const showErrorDetails = useCallback(() => {
+ alert(props.error);
+ }, [props.error]);
+
return
{props.title}
- {props.loading ? : null}
+ {props.loading ? :
+ props.error ?
+
+ : null}
{props.children}
;
}
+const HEADER_SIZE = ipc.platform === 'win32' ? 24 : 14;
+
const styles = StyleSheet.create({
container: {
marginBottom: ipc.platform === 'win32' ? 10 : 0,
@@ -34,11 +45,17 @@ const styles = StyleSheet.create({
},
headerText: {
flex: 1,
- fontSize: ipc.platform === 'win32' ? 24 : 14,
+ fontSize: HEADER_SIZE,
},
activityIndicator: {
marginLeft: 10,
},
+ iconTouchable: {
+ marginLeft: 10,
+ },
+ icon: {
+ fontSize: HEADER_SIZE,
+ },
});
const light = StyleSheet.create({
diff --git a/src/app/browser/main/webservices.tsx b/src/app/browser/main/webservices.tsx
index 72366e7..7bd74aa 100644
--- a/src/app/browser/main/webservices.tsx
+++ b/src/app/browser/main/webservices.tsx
@@ -10,10 +10,11 @@ export default function WebServices(props: {
user: User;
webservices: WebService[];
loading?: boolean;
+ error?: Error;
}) {
if (!props.webservices.length) return null;
- return
+ return
{props.webservices.map(g => )}
diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts
index afbb51d..b025b97 100644
--- a/src/app/main/ipc.ts
+++ b/src/app/main/ipc.ts
@@ -6,7 +6,7 @@ import openWebService, { WebServiceIpc } from './webservices.js';
import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
-import { addNsoAccount, addPctlAccount, askAddNsoAccount, askAddPctlAccount, AuthoriseError } from './na-auth.js';
+import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
diff --git a/src/util/misc.ts b/src/util/misc.ts
index ec1c07b..4aa715b 100644
--- a/src/util/misc.ts
+++ b/src/util/misc.ts
@@ -19,6 +19,14 @@ export function hrduration(duration: number, short = false) {
}
}
+export function hrlist(items: string[]) {
+ if (!items.length) throw new Error('list must not be empty');
+ if (items.length === 1) return items[0];
+
+ const last = items[items.length - 1];
+ return items.slice(0, -1).join(', ') + ' and ' + last;
+}
+
export function timeoutSignal(ms = 10 * 1000) {
const controller = new AbortController();