Files
musicshare/apps/mobile/src/app/(tabs)/index.tsx
T
chrisandClaude Opus 5.5 773e6f0e3f
CI / check (push) Canceled after 0s
Feed: show everyone you follow
The feed only listed people who had played something, so following
someone who hadn't connected a service yet looked like it did nothing.
Under the feed, a "Following" row now shows everyone else you follow,
linking to their profiles, and the empty message says why it's empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-06 15:25:41 -04:00

178 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder';
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, space } from '@/lib/theme';
export default function FeedScreen() {
const { me } = useSession();
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 !== 'now_playing') 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 />
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
<Animated.FlatList
itemLayoutAnimation={LinearTransition.springify().damping(18)}
data={feed ?? []}
keyExtractor={(item) => item.user.id}
contentContainerStyle={styles.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
ListHeaderComponent={
<View style={styles.header}>
<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, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
<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 },
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' },
});