Expo's fetch rejects React Native's { uri, name, type } FormData parts ("Unsupported FormDataPart
implementation"), so every upload failed. Upload an expo-file-system File instead, shrunk to 1024 px
first. The server also takes bigger photos (25 MB, sharp's default pixel limit) and logs why a
picture couldn't be read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
686 lines
24 KiB
TypeScript
686 lines
24 KiB
TypeScript
import { File } from 'expo-file-system';
|
||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { router } from 'expo-router';
|
||
import { useState } from 'react';
|
||
import { Alert, Linking, Platform, StyleSheet, View } from 'react-native';
|
||
import useSWR from 'swr';
|
||
import {
|
||
SERVICE_LABELS,
|
||
STREAMING_SERVICES,
|
||
timeAgo,
|
||
type ConnectionDTO,
|
||
type MeDTO,
|
||
type NowPlayingProvider,
|
||
type StreamingService,
|
||
type NotificationType,
|
||
NOTIFICATION_PREF_LABELS,
|
||
NOTIFICATION_TYPES,
|
||
} from '@musicshare/shared';
|
||
import { Avatar } from '@/components/Avatar';
|
||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||
import { FeaturedPicker } from '@/components/FeaturedPicker';
|
||
import { Screen } from '@/components/Screen';
|
||
import { ServiceName } from '@/components/ServiceName';
|
||
import { SharingControls } from '@/components/SharingControls';
|
||
import { Body, Button, Card, Chip, Field, Heading, ListRow, Row, Title } from '@/components/ui';
|
||
import { api } from '@/lib/api';
|
||
import { WEB_URL } from '@/lib/config';
|
||
import { showError } from '@/lib/errors';
|
||
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||
import { useSession } from '@/lib/session';
|
||
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||
import { colors, radius, space } from '@/lib/theme';
|
||
|
||
export default function SettingsScreen() {
|
||
return (
|
||
<Screen>
|
||
<Title>Settings</Title>
|
||
<ProfileSection />
|
||
<SharingControls />
|
||
<RelinkReminder />
|
||
<MyServices />
|
||
<Notifications />
|
||
<ThisPhone />
|
||
<Connections />
|
||
<FeaturedPicker />
|
||
<AppVersion />
|
||
<Account />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function ProfileSection() {
|
||
const { me, setMe } = useSession();
|
||
const [displayName, setDisplayName] = useState(me?.displayName ?? '');
|
||
const [bio, setBio] = useState(me?.bio ?? '');
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const save = async () => {
|
||
setBusy(true);
|
||
try {
|
||
setMe(await api.updateMe({ displayName: displayName.trim() || null, bio: bio.trim() || null }));
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
setBusy(false);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<Heading>Profile</Heading>
|
||
<AvatarPicker />
|
||
<Field label="Display name" value={displayName} onChangeText={setDisplayName} maxLength={64} />
|
||
<Field label="Bio" value={bio} onChangeText={setBio} maxLength={280} multiline style={{ minHeight: 64 }} />
|
||
<Button title="Save" variant="primary" loading={busy} onPress={save} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function AvatarPicker() {
|
||
const { me, setMe } = useSession();
|
||
const [busy, setBusy] = useState(false);
|
||
if (!me) return null;
|
||
|
||
const run = async (fn: () => Promise<MeDTO>) => {
|
||
setBusy(true);
|
||
try {
|
||
setMe(await fn());
|
||
} catch (err) {
|
||
showError(err, "Couldn't update your picture");
|
||
}
|
||
setBusy(false);
|
||
};
|
||
|
||
const pick = async () => {
|
||
// The system photo picker needs no permission on Android 13+; iOS asks the first time.
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ['images'],
|
||
allowsEditing: true,
|
||
aspect: [1, 1],
|
||
quality: 0.85,
|
||
});
|
||
const asset = result.canceled ? null : result.assets[0];
|
||
if (!asset) return;
|
||
run(async () => {
|
||
// Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
|
||
const side = Math.min(1024, asset.width || 1024);
|
||
const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
|
||
compress: 0.85,
|
||
format: SaveFormat.JPEG,
|
||
});
|
||
const form = new FormData();
|
||
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
|
||
form.append('file', new File(image.uri) as unknown as Blob);
|
||
return api.uploadAvatar(form);
|
||
});
|
||
};
|
||
|
||
const remove = () =>
|
||
Alert.alert('Remove your picture?', undefined, [
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{ text: 'Remove', style: 'destructive', onPress: () => run(() => api.removeAvatar()) },
|
||
]);
|
||
|
||
return (
|
||
<View style={styles.avatarRow}>
|
||
<Avatar user={me} size={72} />
|
||
<View style={{ gap: space.sm }}>
|
||
<Button small variant="primary" loading={busy} title={me.avatarUrl ? 'Change picture' : 'Add a picture'} onPress={pick} />
|
||
{me.avatarUrl && !busy && <Button small title="Remove" onPress={remove} />}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
/** The services you use: songs offer only these, and open in the main one. */
|
||
function MyServices() {
|
||
const { me, setMe } = useSession();
|
||
const mine = me?.services ?? [];
|
||
const save = async (services: StreamingService[], preferredService: StreamingService | null) => {
|
||
try {
|
||
setMe(await api.updateMe({ services: services.length ? services : null, preferredService }));
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
};
|
||
const toggle = (s: StreamingService) => {
|
||
const services = mine.includes(s) ? mine.filter((x) => x !== s) : [...mine, s];
|
||
// The main service has to be one you use; fall back to the first one left.
|
||
const main = me?.preferredService && services.includes(me.preferredService) ? me.preferredService : (services[0] ?? null);
|
||
save(services, main);
|
||
};
|
||
return (
|
||
<Card>
|
||
<Heading>Your music services</Heading>
|
||
<Body muted style={styles.small}>
|
||
Songs only show buttons for the ones you pick.
|
||
</Body>
|
||
<Row>
|
||
{STREAMING_SERVICES.map((s) => (
|
||
<Chip key={s} label={SERVICE_LABELS[s]} selected={mine.includes(s)} onPress={() => toggle(s)} />
|
||
))}
|
||
</Row>
|
||
{mine.length > 1 && (
|
||
<>
|
||
<Heading>Open songs in</Heading>
|
||
<Row>
|
||
{mine.map((s) => (
|
||
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => save(mine, s)} />
|
||
))}
|
||
</Row>
|
||
</>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/** What to be notified about, and push delivery on this phone. */
|
||
function Notifications() {
|
||
const { me, setMe } = useSession();
|
||
const push = usePushStatus();
|
||
const [busy, setBusy] = useState(false);
|
||
if (!me) return null;
|
||
|
||
const toggle = async (type: NotificationType) => {
|
||
try {
|
||
setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }));
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
};
|
||
const turnOn = async () => {
|
||
setBusy(true);
|
||
try {
|
||
const result = await enablePush();
|
||
if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.');
|
||
if (result === 'no-distributor') push.refresh();
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
setBusy(false);
|
||
};
|
||
const turnOff = async () => {
|
||
setBusy(true);
|
||
await disablePush().catch((err) => showError(err));
|
||
push.refresh();
|
||
setBusy(false);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<Heading>Notifications</Heading>
|
||
<Body muted style={styles.small}>
|
||
Notify me about:
|
||
</Body>
|
||
<Row>
|
||
{NOTIFICATION_TYPES.map((t) => (
|
||
<Chip key={t} label={NOTIFICATION_PREF_LABELS[t]} selected={me.notifyPrefs[t]} onPress={() => toggle(t)} />
|
||
))}
|
||
</Row>
|
||
{pushAvailable &&
|
||
(push.enabled ? (
|
||
<>
|
||
<Body muted style={styles.small}>
|
||
On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}.
|
||
</Body>
|
||
<Button small title="Turn off on this phone" loading={busy} onPress={turnOff} style={{ alignSelf: 'flex-start' }} />
|
||
</>
|
||
) : push.hasDistributor ? (
|
||
<Button small variant="primary" title="Turn on for this phone" loading={busy} onPress={turnOn} style={{ alignSelf: 'flex-start' }} />
|
||
) : (
|
||
<>
|
||
<Body muted style={styles.small}>
|
||
musicshare doesn't use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
|
||
them on. You'll still see everything under the bell on the feed without it.
|
||
</Body>
|
||
<Row>
|
||
<Button small variant="primary" title="Get ntfy" onPress={() => Linking.openURL(NTFY_URL)} />
|
||
<Button small title="I've installed it" loading={busy} onPress={turnOn} />
|
||
</Row>
|
||
</>
|
||
))}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/** Android: share from any music app on this phone (no-op elsewhere). */
|
||
function ThisPhone() {
|
||
const { available, granted, playback } = useReader();
|
||
if (!available) return null;
|
||
return (
|
||
<Card>
|
||
<Heading>This phone</Heading>
|
||
{granted ? (
|
||
<Body muted style={styles.small}>
|
||
{playback?.title
|
||
? `Sharing ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''} from ${playback.appName ?? 'a music app'}.`
|
||
: 'On. Anything you play in a music app is shared.'}
|
||
</Body>
|
||
) : (
|
||
<Body muted style={styles.small}>
|
||
Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
|
||
</Body>
|
||
)}
|
||
<Button
|
||
small
|
||
variant={granted ? 'secondary' : 'primary'}
|
||
title={granted ? 'Turn off in Android settings' : 'Turn on'}
|
||
onPress={granted ? openReaderSettings : () => router.push('/share-setup')}
|
||
style={{ alignSelf: 'flex-start' }}
|
||
/>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Connections() {
|
||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
||
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
||
|
||
const run = async (fn: () => Promise<unknown>) => {
|
||
try {
|
||
await fn();
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
await mutate();
|
||
};
|
||
|
||
/** Sign in on the service's site (Plex, Last.fm) and come back to the app. */
|
||
const connectOAuth = (provider: 'plex' | 'lastfm') =>
|
||
run(async () => {
|
||
try {
|
||
await signInWithService(provider);
|
||
} catch (err) {
|
||
if (err instanceof SignInError) Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, err.message);
|
||
else throw err;
|
||
}
|
||
});
|
||
|
||
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
|
||
const linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').catch((e) => showError(e));
|
||
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
|
||
|
||
const disconnect = (provider: NowPlayingProvider) =>
|
||
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{ text: 'Disconnect', style: 'destructive', onPress: () => run(() => api.disconnect(provider)) },
|
||
]);
|
||
|
||
const status = (p: NowPlayingProvider, fallback: string) => {
|
||
const c = byProvider.get(p);
|
||
if (!c) return <Body muted style={styles.small}>{fallback}</Body>;
|
||
if (c.status === 'error') return <Body style={[styles.small, { color: colors.danger }]}>Needs reconnecting: {c.lastError}</Body>;
|
||
return (
|
||
<Body muted style={styles.small}>
|
||
Connected as {c.displayName ?? c.externalUserId}
|
||
{c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}
|
||
</Body>
|
||
);
|
||
};
|
||
|
||
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
|
||
|
||
const linkAppleMusic = () =>
|
||
run(async () => {
|
||
try {
|
||
await connectAppleMusic();
|
||
} catch (err) {
|
||
if (err instanceof AppleMusicPermissionError) Alert.alert('Apple Music access needed', err.message);
|
||
else throw err;
|
||
}
|
||
});
|
||
|
||
return (
|
||
<Card>
|
||
<Heading>Music services</Heading>
|
||
<Body muted style={styles.small}>
|
||
Connect where you listen. Friends see what you're playing in real time.
|
||
</Body>
|
||
|
||
{appleMusicAvailable && (
|
||
<View style={[styles.connectionColumn, styles.connection]}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service="apple_music" />
|
||
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
|
||
</View>
|
||
{isActive('apple_music') ? (
|
||
<Button small title="Disconnect" onPress={() => disconnect('apple_music')} />
|
||
) : (
|
||
<Button small variant="primary" title={byProvider.has('apple_music') ? 'Reconnect' : 'Connect'} onPress={linkAppleMusic} />
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{/* Spotify caps new apps at a handful of accounts, so Spotify comes in through
|
||
its own Last.fm / ListenBrainz link (or the Android toggle). */}
|
||
<View style={[styles.connectionColumn, styles.connection]}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service="spotify" />
|
||
<Body muted style={styles.small}>
|
||
{Platform.OS === 'android'
|
||
? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.'
|
||
: spotifyLinked
|
||
? 'Through Spotify’s link to Last.fm or ListenBrainz. Make sure it’s linked there.'
|
||
: 'Connect it through Last.fm or ListenBrainz: Spotify sends your plays there, and we share them.'}
|
||
</Body>
|
||
</View>
|
||
<Button small variant={spotifyLinked ? 'secondary' : 'primary'} title={spotifyLinked ? 'Check link' : 'Set up'} onPress={() => router.push('/spotify-setup')} />
|
||
</View>
|
||
|
||
<View style={[styles.connectionColumn, styles.connection]}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service="plex" />
|
||
{status('plex', 'Music you play from your Plex servers')}
|
||
</View>
|
||
{isActive('plex') ? (
|
||
<Button small title="Disconnect" onPress={() => disconnect('plex')} />
|
||
) : (
|
||
<Button small variant="primary" title={byProvider.has('plex') ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth('plex')} />
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.connectionColumn}>
|
||
<View style={styles.connection}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service="lastfm" />
|
||
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
|
||
</View>
|
||
{byProvider.has('lastfm') ? (
|
||
<Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
|
||
) : (
|
||
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
|
||
)}
|
||
</View>
|
||
{byProvider.has('lastfm') ? (
|
||
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
|
||
) : (
|
||
<Button small title="No account? Create one" onPress={() => openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
|
||
)}
|
||
</View>
|
||
|
||
<UsernameConnection
|
||
provider="listenbrainz"
|
||
status={status('listenbrainz', 'Open-source scrobbling')}
|
||
active={byProvider.has('listenbrainz')}
|
||
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
||
onDisconnect={() => disconnect('listenbrainz')}
|
||
onLinkSpotify={linkSpotifyOnListenBrainz}
|
||
/>
|
||
|
||
<JellyfinConnection
|
||
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
|
||
active={isActive('jellyfin')}
|
||
onConnect={(input) => run(() => api.connectJellyfin(input))}
|
||
onDisconnect={() => disconnect('jellyfin')}
|
||
/>
|
||
|
||
{connections.some((c) => c.webhookUrl) && (
|
||
<Body muted style={styles.small}>
|
||
Instant updates for Plex/Jellyfin can be set up from Settings on the web.
|
||
</Body>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function UsernameConnection({
|
||
provider,
|
||
status,
|
||
active,
|
||
onConnect,
|
||
onDisconnect,
|
||
onLinkSpotify,
|
||
}: {
|
||
provider: 'lastfm' | 'listenbrainz';
|
||
status: React.ReactNode;
|
||
active: boolean;
|
||
onConnect: (username: string) => Promise<void>;
|
||
onDisconnect: () => void;
|
||
onLinkSpotify: () => void;
|
||
}) {
|
||
const [username, setUsername] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
return (
|
||
<View style={styles.connectionColumn}>
|
||
<View style={styles.connection}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service={provider} />
|
||
{status}
|
||
</View>
|
||
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
|
||
</View>
|
||
{!active && (
|
||
<Row style={{ flexWrap: 'nowrap' }}>
|
||
<View style={{ flex: 1 }}>
|
||
<Field placeholder="Username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
|
||
</View>
|
||
<Button
|
||
small
|
||
variant="primary"
|
||
title="Connect"
|
||
loading={busy}
|
||
disabled={!username.trim()}
|
||
onPress={async () => {
|
||
setBusy(true);
|
||
await onConnect(username.trim());
|
||
setBusy(false);
|
||
}}
|
||
/>
|
||
</Row>
|
||
)}
|
||
{active ? (
|
||
<Button small title={`Link Spotify to ${SERVICE_LABELS[provider]}`} onPress={onLinkSpotify} style={{ alignSelf: 'flex-start' }} />
|
||
) : (
|
||
<>
|
||
<Button small title="No account? Create one" onPress={() => openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
|
||
{provider === 'listenbrainz' && (
|
||
<Body muted style={styles.small}>
|
||
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
|
||
</Body>
|
||
)}
|
||
</>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function JellyfinConnection({
|
||
status,
|
||
active,
|
||
onConnect,
|
||
onDisconnect,
|
||
}: {
|
||
status: React.ReactNode;
|
||
active: boolean;
|
||
onConnect: (input: { serverUrl: string; username: string; password: string }) => Promise<void>;
|
||
onDisconnect: () => void;
|
||
}) {
|
||
const [serverUrl, setServerUrl] = useState('');
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
return (
|
||
<View style={styles.connectionColumn}>
|
||
<View style={styles.connection}>
|
||
<View style={{ flex: 1 }}>
|
||
<ServiceName service="jellyfin" />
|
||
{status}
|
||
</View>
|
||
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
|
||
</View>
|
||
{!active && (
|
||
<View style={{ gap: space.sm }}>
|
||
<Field
|
||
placeholder="https://jellyfin.example.com"
|
||
value={serverUrl}
|
||
onChangeText={setServerUrl}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
keyboardType="url"
|
||
/>
|
||
<Field placeholder="Username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
|
||
<Field placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||
<Button
|
||
small
|
||
variant="primary"
|
||
title="Connect"
|
||
loading={busy}
|
||
disabled={!serverUrl.trim() || !username.trim()}
|
||
style={{ alignSelf: 'flex-start' }}
|
||
onPress={async () => {
|
||
setBusy(true);
|
||
await onConnect({ serverUrl: serverUrl.trim(), username: username.trim(), password });
|
||
setPassword('');
|
||
setBusy(false);
|
||
}}
|
||
/>
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
|
||
function AppVersion() {
|
||
const state = useUpdateState();
|
||
if (!updaterAvailable) return null;
|
||
const status = (() => {
|
||
switch (state.status) {
|
||
case 'checking':
|
||
return 'Checking for updates…';
|
||
case 'upToDate':
|
||
return 'Up to date.';
|
||
case 'downloading':
|
||
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
|
||
case 'ready':
|
||
return `Version ${state.update.version} is ready to install.`;
|
||
case 'installing':
|
||
return `Installing ${state.update.version}…`;
|
||
case 'error':
|
||
return state.message;
|
||
default:
|
||
return null;
|
||
}
|
||
})();
|
||
return (
|
||
<Card>
|
||
<Heading>App version {currentVersion()}</Heading>
|
||
{status && (
|
||
<Body muted style={styles.small}>
|
||
{status}
|
||
</Body>
|
||
)}
|
||
{state.status === 'ready' || state.status === 'installing' ? (
|
||
<Button
|
||
small
|
||
variant="primary"
|
||
title="Install update"
|
||
disabled={state.status === 'installing'}
|
||
onPress={() => installUpdate(true)}
|
||
style={{ alignSelf: 'flex-start' }}
|
||
/>
|
||
) : (
|
||
<Button
|
||
small
|
||
title="Check for updates"
|
||
loading={state.status === 'checking' || state.status === 'downloading'}
|
||
onPress={() => checkForUpdate({ force: true })}
|
||
style={{ alignSelf: 'flex-start' }}
|
||
/>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Account() {
|
||
const { me, setMe, signOut } = useSession();
|
||
const resend = async () => {
|
||
try {
|
||
await api.resendVerification();
|
||
Alert.alert('Sent', `We sent a new link to ${me?.email}.`);
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
};
|
||
const cancelChange = async () => {
|
||
try {
|
||
setMe(await api.cancelEmailChange());
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
};
|
||
const confirmDelete = () =>
|
||
Alert.alert('Delete your account?', 'This permanently removes your profile, follows and connected services.', [
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{
|
||
text: 'Delete',
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
try {
|
||
await api.deleteAccount();
|
||
await signOut();
|
||
} catch (err) {
|
||
showError(err);
|
||
}
|
||
},
|
||
},
|
||
]);
|
||
const email = () =>
|
||
Alert.alert(
|
||
me?.email ?? 'Email',
|
||
me?.pendingEmail
|
||
? `Changing to ${me.pendingEmail}: open the link we sent there.`
|
||
: me?.emailVerified
|
||
? 'Confirmed.'
|
||
: 'Not confirmed yet. Open the link we emailed you.',
|
||
[
|
||
{ text: 'Change email', onPress: () => router.push('/change-email') },
|
||
...(me?.pendingEmail
|
||
? [{ text: 'Cancel the change', onPress: cancelChange }]
|
||
: !me?.emailVerified
|
||
? [{ text: 'Send the link again', onPress: resend }]
|
||
: []),
|
||
{ text: 'Close', style: 'cancel' as const },
|
||
],
|
||
);
|
||
const confirmSignOut = () =>
|
||
Alert.alert('Log out?', undefined, [
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{ text: 'Log out', onPress: signOut },
|
||
]);
|
||
return (
|
||
<Card style={{ gap: 0 }}>
|
||
<Heading style={{ marginBottom: space.sm }}>Account</Heading>
|
||
<ListRow first label="Email" value={me?.emailVerified ? me.email : `${me?.email ?? ''} · not confirmed`} onPress={email} />
|
||
<ListRow label="Password" value="Change" onPress={() => router.push('/change-password')} />
|
||
<ListRow label="Blocked accounts" onPress={() => router.push('/blocked')} />
|
||
<ListRow label="Privacy policy" onPress={() => Linking.openURL(`${WEB_URL}/privacy`)} />
|
||
<ListRow label="Terms of use" onPress={() => Linking.openURL(`${WEB_URL}/terms`)} />
|
||
<ListRow label="Log out" onPress={confirmSignOut} />
|
||
<ListRow label="Delete account" danger onPress={confirmDelete} />
|
||
<Body muted style={[styles.small, { marginTop: space.sm }]}>
|
||
Signed in as @{me?.username}
|
||
</Body>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
small: { fontSize: 13 },
|
||
avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||
connection: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||
connectionColumn: {
|
||
gap: space.sm,
|
||
borderTopWidth: StyleSheet.hairlineWidth,
|
||
borderTopColor: colors.border,
|
||
paddingTop: space.md,
|
||
},
|
||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||
});
|