Feed: show everyone you follow
CI / check (push) Canceled after 0s

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
This commit is contained in:
2026-10-06 15:25:41 -04:00
co-authored by Claude Opus 5.5
parent 601398acd6
commit 773e6f0e3f
3 changed files with 74 additions and 8 deletions
+38 -4
View File
@@ -1,11 +1,12 @@
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RefreshControl, StyleSheet, Text, View } from 'react-native'; import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr'; import useSWR from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground'; import { AmbientBackground } from '@/components/AmbientBackground';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard'; import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder'; import { RelinkReminder } from '@/components/RelinkReminder';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
@@ -24,6 +25,10 @@ export default function FeedScreen() {
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed'); 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. // 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); 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 [refreshing, setRefreshing] = useState(false);
const reader = useReader(); const reader = useReader();
@@ -67,7 +72,7 @@ export default function FeedScreen() {
const refresh = async () => { const refresh = async () => {
setRefreshing(true); setRefreshing(true);
await Promise.all([mutate(), mutateMine()]).catch(() => {}); await Promise.all([mutate(), mutateMine(), mutateFollowing()]).catch(() => {});
setRefreshing(false); setRefreshing(false);
}; };
@@ -115,11 +120,37 @@ export default function FeedScreen() {
ListEmptyComponent={ ListEmptyComponent={
isLoading ? null : ( isLoading ? null : (
<Card> <Card>
<Body muted>Nothing yet. Follow some friends and their music will show up here live.</Body> <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' }} /> <Button small variant="primary" title="Find friends" onPress={() => router.push('/search')} style={{ alignSelf: 'flex-start' }} />
</Card> </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 }) => ( renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}> <Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
<NowPlayingCard <NowPlayingCard
@@ -140,4 +171,7 @@ const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 }, list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
header: { gap: space.md, marginBottom: space.xs }, header: { gap: space.md, marginBottom: space.xs },
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 }, 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' },
}); });
+5
View File
@@ -140,6 +140,11 @@ input:focus, textarea:focus, select:focus {
.list-row.danger .list-label { color: var(--danger); } .list-row.danger .list-label { color: var(--danger); }
.list-panel { padding: 0.25rem 0.4rem 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .list-panel { padding: 0.25rem 0.4rem 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.list-message { margin: 0.5rem 0 0; } .list-message { margin: 0.5rem 0 0; }
.quiet-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: 0.75rem; }
.quiet-person { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; text-decoration: none; font-size: 0.82rem; text-align: center; min-width: 0; }
.quiet-person span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quiet-person:hover span { text-decoration: underline; }
.quiet-more { justify-content: center; color: var(--muted); }
.avatar-picker { display: flex; gap: 1rem; align-items: center; } .avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; } .avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; } .grow { flex: 1; min-width: 0; }
+31 -4
View File
@@ -3,7 +3,8 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Link from 'next/link'; import Link from 'next/link';
import useSWR from 'swr'; import useSWR from 'swr';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Equalizer } from '@/components/Equalizer'; import { Equalizer } from '@/components/Equalizer';
import { NowPlayingCard } from '@/components/NowPlayingCard'; import { NowPlayingCard } from '@/components/NowPlayingCard';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
@@ -23,6 +24,10 @@ function Feed() {
const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher); const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher);
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly. // 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, fetcher); const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null, fetcher);
// Everyone you follow; the feed only has the ones who've played something.
const { data: following } = useSWR<FollowListDTO>(me ? `/users/${me.username}/following` : null, fetcher);
const inFeed = new Set(feed?.map((n) => n.user.id));
const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
useRealtime(true, (msg) => { useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return; if (msg.type !== 'now_playing') return;
@@ -69,9 +74,12 @@ function Feed() {
Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app. Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app.
</p> </p>
)} )}
{feed?.length === 0 && ( {feed?.length === 0 &&
<p className="muted">Nothing yet. Follow some friends and their music will show up here live.</p> (following?.users.length ? (
)} <p className="muted">Nobody you follow has played anything yet. Their music will show up here live.</p>
) : (
<p className="muted">Nothing yet. Find friends above, and their music will show up here live.</p>
))}
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */} {/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
<AnimatePresence initial={true}> <AnimatePresence initial={true}>
{feed?.map((np, i) => ( {feed?.map((np, i) => (
@@ -92,6 +100,25 @@ function Feed() {
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
{quiet.length > 0 && (
<section className="stack">
<h2 className="section-label">Following · nothing played yet</h2>
<div className="card quiet-list">
{quiet.map((u) => (
<Link key={u.id} href={`/u/${u.username}`} className="quiet-person" title={`@${u.username}`}>
<Avatar user={u} size={44} />
<span>{u.displayName ?? u.username}</span>
</Link>
))}
{following?.nextCursor && me && (
<Link href={`/u/${me.username}/following`} className="quiet-person quiet-more">
See all
</Link>
)}
</div>
</section>
)}
</div> </div>
); );
} }