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