Files
musicshare/apps/mobile/src/app/(tabs)/settings.tsx
T
chrisandClaude Opus 5.5 2b02aa8e89
CI / check (push) Canceled after 0s
Profile pictures, display name default, coloured service names
- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-05 23:38:00 -04:00

466 lines
16 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 * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Platform, StyleSheet, View } from 'react-native';
import useSWR from 'swr';
import {
SERVICE_LABELS,
STREAMING_SERVICES,
timeAgo,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
} 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, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
export default function SettingsScreen() {
return (
<Screen>
<Title>Settings</Title>
<ProfileSection />
<SharingControls />
<RelinkReminder />
<PreferredService />
<ThisPhone />
<Connections />
<FeaturedPicker />
<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;
const form = new FormData();
// React Native's FormData takes a file as { uri, name, type }.
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
run(() => 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>
);
}
function PreferredService() {
const { me, setMe } = useSession();
const choose = async (service: (typeof STREAMING_SERVICES)[number]) => {
try {
setMe(await api.updateMe({ preferredService: service }));
} catch (err) {
showError(err);
}
};
return (
<Card>
<Heading>Open songs in</Heading>
<Row>
{STREAMING_SERVICES.map((s) => (
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => choose(s)} />
))}
</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 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' }} />
)}
</View>
<UsernameConnection
provider="listenbrainz"
status={status('listenbrainz', 'Open-source scrobbling')}
active={byProvider.has('listenbrainz')}
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
/>
<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,
}: {
provider: 'lastfm' | 'listenbrainz';
status: React.ReactNode;
active: boolean;
onConnect: (username: string) => Promise<void>;
onDisconnect: () => 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>
)}
</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>
);
}
function Account() {
const { me, signOut } = useSession();
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);
}
},
},
]);
return (
<Card>
<Heading>Account</Heading>
<Body muted style={styles.small}>
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
</Body>
<Row>
<Button small title="Log out" onPress={signOut} />
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
</Row>
</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 },
});