CI / check (push) Canceled after 0s
- 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
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
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'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 },
|
||
});
|