Profile pictures, display name default, coloured service names
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
This commit is contained in:
2026-10-05 23:38:00 -04:00
co-authored by Claude Opus 5.5
parent 9873cc1710
commit 2b02aa8e89
25 changed files with 1746 additions and 22 deletions
+8
View File
@@ -42,6 +42,14 @@
"microphonePermission": false,
"recordAudioAndroid": false
}
],
[
"expo-image-picker",
{
"photosPermission": "musicshare lets you choose a profile picture from your photos.",
"cameraPermission": "musicshare uses the camera to scan friends' QR codes.",
"microphonePermission": false
}
]
],
"experiments": {
+1
View File
@@ -15,6 +15,7 @@
"expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
"expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11",
"expo-router": "~57.0.24",
+61 -8
View File
@@ -1,17 +1,21 @@
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Platform, StyleSheet, Text, View } from 'react-native';
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';
@@ -57,6 +61,7 @@ function ProfileSection() {
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' }} />
@@ -64,6 +69,54 @@ function ProfileSection() {
);
}
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]) => {
@@ -181,7 +234,7 @@ function Connections() {
{appleMusicAvailable && (
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Apple Music</Text>
<ServiceName service="apple_music" />
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
</View>
{isActive('apple_music') ? (
@@ -196,7 +249,7 @@ function Connections() {
its own Last.fm / ListenBrainz link (or the Android toggle). */}
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Spotify</Text>
<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.'
@@ -210,7 +263,7 @@ function Connections() {
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Plex</Text>
<ServiceName service="plex" />
{status('plex', 'Music you play from your Plex servers')}
</View>
{isActive('plex') ? (
@@ -223,7 +276,7 @@ function Connections() {
<View style={styles.connectionColumn}>
<View style={styles.connection}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Last.fm</Text>
<ServiceName service="lastfm" />
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
</View>
{byProvider.has('lastfm') ? (
@@ -280,7 +333,7 @@ function UsernameConnection({
<View style={styles.connectionColumn}>
<View style={styles.connection}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>{SERVICE_LABELS[provider]}</Text>
<ServiceName service={provider} />
{status}
</View>
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
@@ -327,7 +380,7 @@ function JellyfinConnection({
<View style={styles.connectionColumn}>
<View style={styles.connection}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Jellyfin</Text>
<ServiceName service="jellyfin" />
{status}
</View>
{active && <Button small title="Disconnect" onPress={onDisconnect} />}
@@ -398,7 +451,7 @@ function Account() {
const styles = StyleSheet.create({
small: { fontSize: 13 },
provider: { color: colors.text, fontWeight: '700', fontSize: 15 },
avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
connection: { flexDirection: 'row', alignItems: 'center', gap: space.md },
connectionColumn: {
gap: space.sm,
@@ -0,0 +1,31 @@
import { StyleSheet, Text, View } from 'react-native';
import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared';
import { colors, fonts } from '@/lib/theme';
/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */
export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) {
const label = SERVICE_LABELS[service];
return (
<View style={styles.row}>
<View style={[styles.badge, { backgroundColor: SERVICE_COLORS[service] }]}>
<Text style={styles.letter}>{label.slice(0, 1).toUpperCase()}</Text>
</View>
<Text style={styles.name}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 2 },
badge: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.25)',
},
letter: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
name: { color: colors.text, fontFamily: fonts.display, fontSize: 18 },
});