The feed's itemLayoutAnimation left the cards behind when the header above them shrank (your own card's lyrics loading). LRCLIB rate-limits /api/get on its own; fall back to search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
191 lines
8.7 KiB
TypeScript
191 lines
8.7 KiB
TypeScript
import { router } from 'expo-router';
|
||
import { useEffect, useState } from 'react';
|
||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import useSWR, { useSWRConfig } from 'swr';
|
||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||
import { AmbientBackground } from '@/components/AmbientBackground';
|
||
import { ActivityBell } from '@/components/ActivityBell';
|
||
import { Avatar } from '@/components/Avatar';
|
||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||
import { UpdateBanner } from '@/components/UpdateBanner';
|
||
import { SharingControls } from '@/components/SharingControls';
|
||
import { Body, Button, Card, Title } from '@/components/ui';
|
||
import { api } from '@/lib/api';
|
||
import { showError } from '@/lib/errors';
|
||
import { appleMusicAvailable } from '@/lib/appleMusic';
|
||
import { useReader } from '@/lib/nowPlayingReader';
|
||
import { shouldOfferSetup } from '@/lib/setup';
|
||
import { useRealtime } from '@/lib/useRealtime';
|
||
import { useSession } from '@/lib/session';
|
||
import { colors, fonts, space } from '@/lib/theme';
|
||
|
||
export default function FeedScreen() {
|
||
const { me } = useSession();
|
||
const { mutate: mutateGlobal } = useSWRConfig();
|
||
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
|
||
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
|
||
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null);
|
||
// Everyone you follow; the feed only has the ones who've played something.
|
||
const { data: following, mutate: mutateFollowing } = useSWR<FollowListDTO>(me ? `/users/${me.username}/following` : null);
|
||
const inFeed = new Set(feed?.map((n) => n.user.id));
|
||
const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
const reader = useReader();
|
||
const { data: connections } = useSWR<ConnectionDTO[]>(appleMusicAvailable ? '/connections' : null);
|
||
const offerAppleMusic = appleMusicAvailable && connections !== undefined && connections.length === 0;
|
||
|
||
// First run on Android: open the one-tap setup once (deferred so navigation has mounted).
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
shouldOfferSetup().then((offer) => offer && router.push('/share-setup'));
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
useRealtime(true, (msg) => {
|
||
if (msg.type === 'notification') {
|
||
mutateGlobal('/notifications');
|
||
return;
|
||
}
|
||
if (msg.data.user.id === me?.id) {
|
||
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
|
||
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 Promise.all([mutate(), mutateMine(), mutateFollowing()]).catch(() => {});
|
||
setRefreshing(false);
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||
<AmbientBackground />
|
||
{/* No itemLayoutAnimation: it leaves the cards behind when the header above them changes height
|
||
(e.g. your own card's lyrics loading), opening a gap under "Friends listening". */}
|
||
<Animated.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}>
|
||
<View style={styles.topBar}>
|
||
<Text style={styles.brand}>musicshare</Text>
|
||
<ActivityBell />
|
||
</View>
|
||
<UpdateBanner />
|
||
<SharingControls compact />
|
||
<RelinkReminder />
|
||
{offerAppleMusic && (
|
||
<Card>
|
||
<Body>Connect Apple Music so friends see what you play. One tap.</Body>
|
||
<Button small variant="primary" title="Connect" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
)}
|
||
{reader.available && !reader.granted && (
|
||
<Card>
|
||
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
||
<Button small variant="primary" title="Turn on" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
|
||
</Card>
|
||
)}
|
||
{mine?.nowPlaying && (
|
||
<View style={{ gap: space.sm }}>
|
||
<Text style={styles.sectionLabel}>YOU</Text>
|
||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||
</View>
|
||
)}
|
||
<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>
|
||
{following?.users.length
|
||
? 'Nobody you follow has played anything yet. Their music will show up here live.'
|
||
: '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>
|
||
)
|
||
}
|
||
ListFooterComponent={
|
||
quiet.length > 0 ? (
|
||
<View style={{ gap: space.sm, marginTop: space.md }}>
|
||
<Text style={styles.sectionLabel}>FOLLOWING · NOTHING PLAYED YET</Text>
|
||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.quiet}>
|
||
{quiet.map((u) => (
|
||
<Pressable key={u.id} onPress={() => router.push(`/u/${u.username}`)} style={styles.quietPerson} accessibilityLabel={u.displayName ?? u.username}>
|
||
<Avatar user={u} size={52} />
|
||
<Text style={styles.quietName} numberOfLines={1}>
|
||
{u.displayName ?? u.username}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
{following?.nextCursor && me && (
|
||
<Pressable onPress={() => router.push(`/u/${me.username}/following`)} style={[styles.quietPerson, { justifyContent: 'center' }]}>
|
||
<Text style={[styles.quietName, { color: colors.muted }]}>See all</Text>
|
||
</Pressable>
|
||
)}
|
||
</ScrollView>
|
||
</View>
|
||
) : null
|
||
}
|
||
renderItem={({ item }) => (
|
||
<Animated.View entering={FadeIn.duration(180)}>
|
||
<NowPlayingCard
|
||
np={item}
|
||
preferred={me?.preferredService}
|
||
pinned={Boolean(item.pinnedAt)}
|
||
onTogglePin={() => togglePin(item)}
|
||
/>
|
||
</Animated.View>
|
||
)}
|
||
/>
|
||
</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 },
|
||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
|
||
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
|
||
quiet: { gap: space.lg, paddingVertical: space.xs },
|
||
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
|
||
quietName: { color: colors.text, fontSize: 12, textAlign: 'center' },
|
||
});
|