Files
musicshare/apps/mobile/src/app/(tabs)/settings.tsx
T
chrisandClaude Opus 5.5 31aed8dec4 Fix profile picture uploads from the app
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
2026-10-15 13:20:12 -04:00

686 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&apos;t use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
them on. You&apos;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&apos;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 },
});