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,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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user