Add Expo mobile app for iOS and Android
Native tabs for feed, friend search, profile and settings, with feature parity with the web app. Sessions are stored in SecureStore, realtime disconnects in the background, and Spotify/Plex sign-in returns to the app via the URL scheme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<NativeTabs
|
||||
backgroundColor={colors.bg}
|
||||
indicatorColor={colors.surface2}
|
||||
labelStyle={{ selected: { color: colors.text } }}
|
||||
tintColor={colors.accent}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf={{ default: 'music.note.list', selected: 'music.note.list' }} md="queue_music" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="search">
|
||||
<NativeTabs.Trigger.Label>Friends</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="person.2" md="group" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="profile">
|
||||
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf={{ default: 'person.crop.circle', selected: 'person.crop.circle.fill' }} md="account_circle" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf={{ default: 'gearshape', selected: 'gearshape.fill' }} md="settings" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import { sortFeed, type FeedItemDTO } from '@musicshare/shared';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { Body, Button, Card, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const { me } = useSession();
|
||||
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type !== 'now_playing' || msg.data.user.id === me?.id) return;
|
||||
mutate(
|
||||
(current = []) => {
|
||||
const existing = current.find((n) => n.user.id === msg.data.user.id);
|
||||
return sortFeed([{ ...msg.data, pinnedAt: existing?.pinnedAt ?? null }, ...current.filter((n) => n !== existing)]);
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
});
|
||||
|
||||
const togglePin = async (item: FeedItemDTO) => {
|
||||
const pinnedAt = item.pinnedAt ? null : new Date().toISOString();
|
||||
const previous = feed;
|
||||
mutate(sortFeed((feed ?? []).map((n) => (n.user.id === item.user.id ? { ...n, pinnedAt } : n))), { revalidate: false });
|
||||
try {
|
||||
await (pinnedAt ? api.pin(item.user.username) : api.unpin(item.user.username));
|
||||
} catch (err) {
|
||||
mutate(previous, { revalidate: false });
|
||||
showError(err, "Couldn't pin");
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
await mutate().catch(() => {});
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<FlatList
|
||||
data={feed ?? []}
|
||||
keyExtractor={(item) => item.user.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<Title>Friends listening</Title>
|
||||
{!me?.preferredService && (
|
||||
<Card>
|
||||
<Body>Pick your streaming service so songs open in the right app.</Body>
|
||||
<Button small title="Choose service" onPress={() => router.push('/settings')} style={{ alignSelf: 'flex-start' }} />
|
||||
</Card>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
isLoading ? null : (
|
||||
<Card>
|
||||
<Body muted>Nothing yet. Follow some friends and their music will show up here live.</Body>
|
||||
<Button small variant="primary" title="Find friends" onPress={() => router.push('/search')} style={{ alignSelf: 'flex-start' }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NowPlayingCard
|
||||
np={item}
|
||||
preferred={me?.preferredService}
|
||||
pinned={Boolean(item.pinnedAt)}
|
||||
onTogglePin={() => togglePin(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
|
||||
header: { gap: space.md, marginBottom: space.xs },
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ProfileView } from '@/components/ProfileView';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
export default function MyProfileScreen() {
|
||||
const { me } = useSession();
|
||||
return me ? <ProfileView username={me.username} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Body, Field, Title } from '@/components/ui';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [q, setQ] = useState('');
|
||||
const query = useDebounced(q.trim(), 250);
|
||||
const { data } = useSWR<UserSummaryDTO[]>(query.length >= 2 ? `/users/search?q=${encodeURIComponent(query)}` : null);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<FlatList
|
||||
data={query.length >= 2 ? (data ?? []) : []}
|
||||
keyExtractor={(u) => u.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: space.md }}>
|
||||
<Title>Find friends</Title>
|
||||
<Field
|
||||
placeholder="Search by name or username"
|
||||
value={q}
|
||||
onChangeText={setQ}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={query.length >= 2 && data ? <Body muted>No one found.</Body> : null}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/u/${item.username}`)} accessibilityRole="link">
|
||||
<Avatar user={item} size={40} />
|
||||
<View>
|
||||
<Text style={styles.name}>{item.displayName ?? item.username}</Text>
|
||||
<Text style={styles.handle}>@{item.username}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
list: { padding: space.lg, gap: space.sm },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
handle: { color: colors.muted, fontSize: 13 },
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
STREAMING_SERVICES,
|
||||
timeAgo,
|
||||
type ConnectionDTO,
|
||||
type NowPlayingProvider,
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
<Title>Settings</Title>
|
||||
<ProfileSection />
|
||||
<PreferredService />
|
||||
<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>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const connectOAuth = (provider: 'spotify' | 'plex') =>
|
||||
run(async () => {
|
||||
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
|
||||
const returnTo = Linking.createURL('settings');
|
||||
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
|
||||
if (result.type === 'success') {
|
||||
const error = Linking.parse(result.url).queryParams?.error;
|
||||
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
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>
|
||||
|
||||
{(['spotify', 'plex'] as const).map((p) => (
|
||||
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider}>{SERVICE_LABELS[p]}</Text>
|
||||
{status(p, p === 'plex' ? 'Music you play from your Plex servers' : 'Live from your Spotify account')}
|
||||
</View>
|
||||
{isActive(p) ? (
|
||||
<Button small title="Disconnect" onPress={() => disconnect(p)} />
|
||||
) : (
|
||||
<Button small variant="primary" title={byProvider.has(p) ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth(p)} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(['lastfm', 'listenbrainz'] as const).map((p) => (
|
||||
<UsernameConnection
|
||||
key={p}
|
||||
provider={p}
|
||||
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')}
|
||||
active={byProvider.has(p)}
|
||||
onConnect={(username) => run(() => api.connectUsername(p, username))}
|
||||
onDisconnect={() => disconnect(p)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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 }}>
|
||||
<Text style={styles.provider}>{SERVICE_LABELS[provider]}</Text>
|
||||
{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 }}>
|
||||
<Text style={styles.provider}>Jellyfin</Text>
|
||||
{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 FeaturedPicker() {
|
||||
const [q, setQ] = useState('');
|
||||
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const query = useDebounced(q.trim(), 300);
|
||||
const { data: results } = useSWR<TrackSearchResultDTO[]>(
|
||||
query.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(query)}` : null,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Pin a song to your profile, with an optional note about why.
|
||||
</Body>
|
||||
{selected ? (
|
||||
<>
|
||||
<View style={styles.result}>
|
||||
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.provider} numberOfLines={1}>
|
||||
{selected.title}
|
||||
</Text>
|
||||
<Body muted style={styles.small}>
|
||||
{selected.artist}
|
||||
</Body>
|
||||
</View>
|
||||
<Button small title="Change" onPress={() => (setSelected(null), setSaved(false))} />
|
||||
</View>
|
||||
<Field
|
||||
placeholder="Say something about it (optional)"
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
maxLength={280}
|
||||
multiline
|
||||
style={{ minHeight: 64 }}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
title={saved ? 'Featured ✓' : 'Feature this song'}
|
||||
loading={busy}
|
||||
onPress={save}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field placeholder="Search for a song" value={q} onChangeText={setQ} autoCorrect={false} returnKeyType="search" />
|
||||
{results?.map((r) => (
|
||||
<Pressable key={r.deezerId} style={styles.result} onPress={() => setSelected(r)} accessibilityRole="button">
|
||||
{r.artworkUrl && <Image source={r.artworkUrl} style={styles.thumb} />}
|
||||
<Text style={styles.resultText} numberOfLines={1}>
|
||||
{r.title} <Text style={{ color: colors.muted }}>· {r.artist}</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
provider: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import { AppState, StyleSheet, View } from 'react-native';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { Body, Button, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
const theme = {
|
||||
...DarkTheme,
|
||||
colors: { ...DarkTheme.colors, background: colors.bg, card: colors.bg, text: colors.text, primary: colors.accent, border: colors.border },
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider value={theme}>
|
||||
<StatusBar style="light" />
|
||||
<SessionProvider>
|
||||
<AppNavigator />
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppNavigator() {
|
||||
const { status, me, retry } = useSession();
|
||||
const signedIn = status === 'signedIn';
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'loading') SplashScreen.hideAsync();
|
||||
}, [status]);
|
||||
|
||||
if (status === 'loading') return null;
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
<View style={styles.offline}>
|
||||
<Title>Can't reach musicshare</Title>
|
||||
<Body muted>Check your connection and try again.</Body>
|
||||
<Button title="Retry" variant="primary" onPress={retry} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed by user so signing out (or in as someone else) starts from an empty cache.
|
||||
<SWRConfig
|
||||
key={me?.id ?? 'anon'}
|
||||
value={{
|
||||
fetcher: (path: string) => api.get(path),
|
||||
provider: () => new Map(),
|
||||
isVisible: () => AppState.currentState === 'active',
|
||||
initFocus(callback) {
|
||||
const sub = AppState.addEventListener('change', (s) => s === 'active' && callback());
|
||||
return () => sub.remove();
|
||||
},
|
||||
}}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}>
|
||||
<Stack.Protected guard={signedIn}>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="register" options={{ title: 'Create account' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
|
||||
export default function LoginScreen() {
|
||||
return <AuthForm mode="login" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
return <AuthForm mode="register" />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { ProfileView } from '@/components/ProfileView';
|
||||
|
||||
export default function UserScreen() {
|
||||
const { username } = useLocalSearchParams<{ username: string }>();
|
||||
// the stack header already handles the top inset
|
||||
return <ProfileView username={username} edges={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Screen } from './Screen';
|
||||
import { Body, Button, Field, Title } from './ui';
|
||||
|
||||
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
const { signIn } = useSession();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = mode === 'login' ? await api.login({ email, password }) : await api.register({ username, email, password });
|
||||
await signIn(res); // the protected stack swaps to the tabs automatically
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<Screen edges={mode === 'login' ? ['top'] : []} contentContainerStyle={styles.content}>
|
||||
{mode === 'login' && (
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Title style={{ fontSize: 34 }}>musicshare</Title>
|
||||
<Body muted>See what your friends are playing, and open it in your own app.</Body>
|
||||
</View>
|
||||
)}
|
||||
{mode === 'register' && (
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="username-new"
|
||||
textContentType="username"
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
textContentType={mode === 'login' ? 'password' : 'newPassword'}
|
||||
onSubmitEditing={submit}
|
||||
returnKeyType="go"
|
||||
/>
|
||||
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
||||
<Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} />
|
||||
{mode === 'login' ? (
|
||||
<Link href="/register" style={styles.link}>
|
||||
New here? Create an account
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/login" style={styles.link} dismissTo>
|
||||
Already have an account? Log in
|
||||
</Link>
|
||||
)}
|
||||
</Screen>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, justifyContent: 'center' },
|
||||
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { UserSummaryDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
export function Avatar({ user, size = 40 }: { user: UserSummaryDTO; size?: number }) {
|
||||
const shape = { width: size, height: size, borderRadius: size / 2 };
|
||||
if (user.avatarUrl) return <Image source={user.avatarUrl} style={shape} contentFit="cover" />;
|
||||
return (
|
||||
<View style={[styles.empty, shape]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center' },
|
||||
letter: { color: colors.muted, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { Card } from './ui';
|
||||
|
||||
export function NowPlayingCard({
|
||||
np,
|
||||
preferred,
|
||||
showUser = true,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
np: NowPlayingDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
showUser?: boolean;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card highlighted={pinned}>
|
||||
<View style={styles.header}>
|
||||
{showUser && (
|
||||
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
|
||||
<Avatar user={np.user} size={28} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{np.user.displayName ?? np.user.username}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.statusRow}>
|
||||
{np.isPlaying && <View style={styles.dot} />}
|
||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<Text style={styles.via}> · {SERVICE_LABELS[np.source]}</Text>
|
||||
</Text>
|
||||
{onTogglePin && (
|
||||
<Pressable
|
||||
onPress={onTogglePin}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
|
||||
accessibilityState={{ selected: pinned }}>
|
||||
<Text style={[styles.pin, pinned && { color: colors.pin }]}>{pinned ? '★' : '☆'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
||||
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
||||
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs, flexShrink: 0 },
|
||||
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.live },
|
||||
status: { color: colors.muted, fontSize: 13 },
|
||||
via: { color: colors.muted },
|
||||
pin: { color: colors.muted, fontSize: 20, marginLeft: space.xs },
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { ProfileDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { NowPlayingCard } from './NowPlayingCard';
|
||||
import { Screen } from './Screen';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { Body, Button, Card, Heading, Row, Title } from './ui';
|
||||
|
||||
export function ProfileView({ username, edges }: { username: string; edges?: ('top' | 'bottom')[] }) {
|
||||
const { me } = useSession();
|
||||
const { data: profile, error, mutate } = useSWR<ProfileDTO>(`/users/${username}`);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
|
||||
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: false });
|
||||
}
|
||||
});
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
await mutate();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Screen edges={edges}>
|
||||
<Body muted>User not found.</Body>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
if (!profile) return <Screen edges={edges} />;
|
||||
const { user } = profile;
|
||||
|
||||
return (
|
||||
<Screen
|
||||
edges={edges}
|
||||
refreshing={refreshing}
|
||||
onRefresh={async () => {
|
||||
setRefreshing(true);
|
||||
await mutate().catch(() => {});
|
||||
setRefreshing(false);
|
||||
}}>
|
||||
<View style={styles.header}>
|
||||
<Avatar user={user} size={72} />
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
|
||||
<Text style={styles.meta}>
|
||||
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{user.bio && <Body>{user.bio}</Body>}
|
||||
|
||||
<Row>
|
||||
{profile.isSelf ? (
|
||||
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
small
|
||||
variant={profile.isFollowing ? 'secondary' : 'primary'}
|
||||
title={profile.isFollowing ? 'Following' : 'Follow'}
|
||||
disabled={busy}
|
||||
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
|
||||
/>
|
||||
{profile.isFollowing && (
|
||||
<Button
|
||||
small
|
||||
title={profile.isPinned ? '★ Pinned' : '☆ Pin to top'}
|
||||
disabled={busy}
|
||||
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{profile.featured && (
|
||||
<Card>
|
||||
<Heading>Featured song</Heading>
|
||||
<TrackCard track={profile.featured.track} preferred={me?.preferredService} />
|
||||
{profile.featured.comment && <Text style={styles.quote}>“{profile.featured.comment}”</Text>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{profile.nowPlaying ? (
|
||||
<NowPlayingCard np={profile.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||
) : (
|
||||
<Body muted>Nothing playing right now.</Body>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||
meta: { color: colors.muted, fontSize: 13 },
|
||||
quote: {
|
||||
color: colors.text,
|
||||
fontStyle: 'italic',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.accent,
|
||||
paddingLeft: space.md,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { RefreshControl, ScrollView, StyleSheet, type ScrollViewProps } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
/** Scrollable, safe-area-aware page with optional pull-to-refresh. */
|
||||
export function Screen({
|
||||
children,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
edges = ['top'],
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: ScrollViewProps & { onRefresh?: () => void; refreshing?: boolean; edges?: ('top' | 'bottom')[] }) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={edges}>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={[styles.content, contentContainerStyle]}
|
||||
refreshControl={
|
||||
onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.muted} /> : undefined
|
||||
}
|
||||
{...props}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl * 3 },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Button } from './ui';
|
||||
|
||||
/**
|
||||
* Links are https universal/app links, so Linking.openURL hands them to the
|
||||
* native Spotify / Apple Music / YouTube Music app when it's installed.
|
||||
*/
|
||||
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
||||
|
||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
{track.artworkUrl ? (
|
||||
<Image source={track.artworkUrl} style={styles.artwork} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.artwork, styles.artworkEmpty]}>
|
||||
<Text style={styles.note}>♪</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
{track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
||||
</Text>
|
||||
{preferred && primary && (
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={`${primary.exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`}
|
||||
onPress={() => open(primary.url)}
|
||||
style={styles.primary}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.services}>
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||
<Text style={[styles.service, !exact && { opacity: 0.6 }]}>{SERVICE_LABELS[s]}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||
artwork: { width: 88, height: 88, borderRadius: radius.md, backgroundColor: colors.surface2 },
|
||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||
note: { color: colors.muted, fontSize: 28 },
|
||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
muted: { color: colors.muted },
|
||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||
services: { flexDirection: 'row', flexWrap: 'wrap', columnGap: space.md, rowGap: space.xs, marginTop: space.sm },
|
||||
service: { color: colors.muted, fontSize: 12 },
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { forwardRef } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableProps,
|
||||
type TextInputProps,
|
||||
type TextProps,
|
||||
type ViewProps,
|
||||
} from 'react-native';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
export function Title({ style, ...props }: TextProps) {
|
||||
return <Text style={[styles.title, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Heading({ style, ...props }: TextProps) {
|
||||
return <Text style={[styles.heading, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Body({ style, muted, ...props }: TextProps & { muted?: boolean }) {
|
||||
return <Text style={[styles.body, muted && styles.muted, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Card({ style, highlighted, ...props }: ViewProps & { highlighted?: boolean }) {
|
||||
return <View style={[styles.card, highlighted && styles.cardHighlighted, style]} {...props} />;
|
||||
}
|
||||
|
||||
export function Row({ style, ...props }: ViewProps) {
|
||||
return <View style={[styles.row, style]} {...props} />;
|
||||
}
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger';
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
variant = 'secondary',
|
||||
loading,
|
||||
small,
|
||||
style,
|
||||
disabled,
|
||||
...props
|
||||
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
small && styles.buttonSmall,
|
||||
variant === 'primary' && styles.buttonPrimary,
|
||||
variant === 'danger' && styles.buttonDanger,
|
||||
(pressed || disabled) && { opacity: 0.7 },
|
||||
style,
|
||||
]}
|
||||
{...props}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
|
||||
{ label, style, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<View style={{ gap: space.xs }}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput ref={ref} placeholderTextColor={colors.muted} style={[styles.input, style]} {...props} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
onPress={onPress}
|
||||
style={[styles.chip, selected && styles.chipSelected]}>
|
||||
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: { color: colors.text, fontSize: 28, fontWeight: '800', letterSpacing: -0.5 },
|
||||
heading: { color: colors.text, fontSize: 17, fontWeight: '700', marginBottom: space.xs },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||
muted: { color: colors.muted },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.lg,
|
||||
padding: space.lg,
|
||||
gap: space.md,
|
||||
},
|
||||
cardHighlighted: { borderColor: colors.accent, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
||||
button: {
|
||||
minHeight: 44,
|
||||
paddingHorizontal: space.lg,
|
||||
borderRadius: radius.pill,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
buttonSmall: { minHeight: 34, paddingHorizontal: space.md },
|
||||
buttonPrimary: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||||
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||
buttonTextSmall: { fontSize: 13 },
|
||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
||||
input: {
|
||||
minHeight: 44,
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface2,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface2,
|
||||
},
|
||||
chipSelected: { backgroundColor: colors.accent, borderColor: colors.accent },
|
||||
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApiClient } from '@musicshare/shared';
|
||||
import { API_URL } from './config';
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
/** Set by the session provider; read on every request. */
|
||||
export function setApiToken(value: string | null) {
|
||||
token = value;
|
||||
}
|
||||
export const getApiToken = () => token;
|
||||
|
||||
export const api = createApiClient({ baseUrl: API_URL, getToken: getApiToken });
|
||||
@@ -0,0 +1,10 @@
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
/**
|
||||
* In development the API runs on the same machine as Metro, so reuse the host
|
||||
* Metro is reachable on (your LAN IP); "localhost" on a phone is the phone.
|
||||
* Set EXPO_PUBLIC_API_URL for staging/production builds.
|
||||
*/
|
||||
const devHost = Constants.expoConfig?.hostUri?.split(':')[0];
|
||||
|
||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000');
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
export const showError = (err: unknown, title = 'Something went wrong') =>
|
||||
Alert.alert(title, err instanceof Error ? err.message : String(err));
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
||||
import { api, setApiToken } from './api';
|
||||
|
||||
const TOKEN_KEY = 'musicshare.session';
|
||||
|
||||
type Status = 'loading' | 'signedOut' | 'signedIn' | 'offline';
|
||||
|
||||
interface Session {
|
||||
status: Status;
|
||||
me: MeDTO | null;
|
||||
signIn(res: AuthResponse): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
setMe(me: MeDTO): void;
|
||||
retry(): void;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<Session | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
const [me, setMe] = useState<MeDTO | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const stored = await SecureStore.getItemAsync(TOKEN_KEY);
|
||||
if (!stored) return !cancelled && setStatus('signedOut');
|
||||
setApiToken(stored);
|
||||
try {
|
||||
const user = await api.me();
|
||||
if (cancelled) return;
|
||||
setMe(user);
|
||||
setStatus('signedIn');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
setApiToken(null);
|
||||
setStatus('signedOut');
|
||||
} else {
|
||||
// keep the token; the API is just unreachable right now
|
||||
setStatus('offline');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const signIn = useCallback(async (res: AuthResponse) => {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, res.token);
|
||||
setApiToken(res.token);
|
||||
setMe(res.user);
|
||||
setStatus('signedIn');
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await api.logout().catch(() => {});
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
setApiToken(null);
|
||||
setMe(null);
|
||||
setStatus('signedOut');
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, me, signIn, signOut, setMe, retry: () => setAttempt((n) => n + 1) }),
|
||||
[status, me, signIn, signOut],
|
||||
);
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): Session {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error('useSession must be used inside SessionProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Mirrors the web palette (apps/web/src/app/globals.css). */
|
||||
export const colors = {
|
||||
bg: '#0e0f13',
|
||||
surface: '#171920',
|
||||
surface2: '#20232c',
|
||||
border: '#2a2e39',
|
||||
text: '#eceef3',
|
||||
muted: '#9097a6',
|
||||
accent: '#7c5cff',
|
||||
accentText: '#ffffff',
|
||||
live: '#3ddc84',
|
||||
danger: '#ff5c6c',
|
||||
pin: '#f5c542',
|
||||
} as const;
|
||||
|
||||
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
||||
export const radius = { sm: 6, md: 10, lg: 14, pill: 999 } as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounced<T>(value: T, ms: number): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useEffectEvent } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import type { RealtimeMessage } from '@musicshare/shared';
|
||||
import { getApiToken } from './api';
|
||||
import { API_URL } from './config';
|
||||
|
||||
/** React Native's WebSocket accepts headers as a third argument; the DOM typings don't know that. */
|
||||
type NativeWebSocketCtor = new (
|
||||
url: string,
|
||||
protocols?: string | string[],
|
||||
options?: { headers: Record<string, string> },
|
||||
) => WebSocket;
|
||||
const NativeWebSocket = WebSocket as unknown as NativeWebSocketCtor;
|
||||
|
||||
/**
|
||||
* Live now-playing updates for the people you follow. Disconnects in the
|
||||
* background (so the server stops fast-polling your friends on your behalf)
|
||||
* and reconnects with backoff when the app is active again.
|
||||
*/
|
||||
export function useRealtime(enabled: boolean, onMessage: (msg: RealtimeMessage) => void) {
|
||||
const handleMessage = useEffectEvent(onMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let socket: WebSocket | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let attempts = 0;
|
||||
let active = AppState.currentState === 'active';
|
||||
|
||||
const connect = () => {
|
||||
const token = getApiToken();
|
||||
if (!token || !active) return;
|
||||
socket = new NativeWebSocket(API_URL.replace(/^http/, 'ws') + '/ws', undefined, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
socket.onopen = () => (attempts = 0);
|
||||
socket.onmessage = (e) => handleMessage(JSON.parse(e.data as string) as RealtimeMessage);
|
||||
socket.onclose = (e) => {
|
||||
socket = null;
|
||||
if (!active || e.code === 4401) return;
|
||||
timer = setTimeout(connect, Math.min(30_000, 1000 * 2 ** attempts++));
|
||||
};
|
||||
};
|
||||
const disconnect = () => {
|
||||
clearTimeout(timer);
|
||||
socket?.close();
|
||||
socket = null;
|
||||
};
|
||||
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
const nowActive = state === 'active';
|
||||
if (nowActive === active) return;
|
||||
active = nowActive;
|
||||
if (active) connect();
|
||||
else disconnect();
|
||||
});
|
||||
connect();
|
||||
return () => {
|
||||
active = false;
|
||||
sub.remove();
|
||||
disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user