Friends tab shows who you follow and who follows you

The app's Friends tab was only a search box. It now lists Following and
Followers (with Follow / Remove buttons) until you type a search. The
website gets the same Friends page and tab; search moves there from the
top of the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 20:43:03 -04:00
co-authored by Claude Opus 5.5
parent a4dad724ad
commit 08631589e3
5 changed files with 174 additions and 66 deletions
+55 -20
View File
@@ -1,37 +1,56 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { router, useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import type { UserSummaryDTO } from '@musicshare/shared';
import type { FollowListItemDTO, UserSummaryDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { Avatar } from '@/components/Avatar';
import { Body, Button, Field, Row, Title } from '@/components/ui';
import { PersonRow, emptyFollowText, useFollowList } from '@/components/FollowList';
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
import { useSession } from '@/lib/session';
import { useDebounced } from '@/lib/useDebounced';
import { colors, space } from '@/lib/theme';
export default function SearchScreen() {
/** Friends: who you follow and who follows you, plus search and QR codes to find new people. */
export default function FriendsScreen() {
const { me } = useSession();
const [q, setQ] = useState('');
const [tab, setTab] = useState<'following' | 'followers'>('following');
const query = useDebounced(q.trim(), 250);
const { data } = useSWR<UserSummaryDTO[]>(query.length >= 2 ? `/users/search?q=${encodeURIComponent(query)}` : null);
const searching = query.length >= 2;
const { data: found } = useSWR<UserSummaryDTO[]>(searching ? `/users/search?q=${encodeURIComponent(query)}` : null);
const list = useFollowList(me?.username, tab);
// Follows change on profiles and elsewhere; catch up when coming back to this tab.
const { refresh } = list;
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
// One list for both, so the search field keeps focus while results swap in.
const rows: (UserSummaryDTO | FollowListItemDTO)[] = searching ? (found ?? []) : list.users;
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<AmbientBackground />
<FlatList
data={query.length >= 2 ? (data ?? []) : []}
data={rows}
keyExtractor={(u) => u.id}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.list}
onEndReached={searching ? undefined : list.loadMore}
ListHeaderComponent={
<View style={{ gap: space.md }}>
<Title>Find friends</Title>
<Title>Friends</Title>
<Row>
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
<Button small title="My code" onPress={() => router.push('/qr')} />
</Row>
<Field
placeholder="Search by name or username"
placeholder="Find people by name or username"
value={q}
onChangeText={setQ}
autoCapitalize="none"
@@ -39,18 +58,34 @@ export default function SearchScreen() {
returnKeyType="search"
clearButtonMode="while-editing"
/>
{!searching && (
<Row>
<Chip label="Following" selected={tab === 'following'} onPress={() => setTab('following')} />
<Chip label="Followers" selected={tab === 'followers'} onPress={() => setTab('followers')} />
</Row>
)}
</View>
}
ListEmptyComponent={query.length >= 2 && data ? <Body muted>No one found.</Body> : null}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => router.push(`/u/${item.username}`)} accessibilityRole="link">
<Avatar user={item} size={40} />
<View>
<Text style={styles.name}>{item.displayName ?? item.username}</Text>
<Text style={styles.handle}>@{item.username}</Text>
</View>
</Pressable>
)}
ListEmptyComponent={
searching ? (
found ? <Body muted>No one found.</Body> : null
) : list.isLoading ? null : (
<Body muted>{emptyFollowText(tab)}</Body>
)
}
renderItem={({ item }) =>
'isFollowing' in item ? (
<PersonRow user={item} list={list} removable={tab === 'followers'} />
) : (
<Pressable style={styles.row} onPress={() => router.push(`/u/${item.username}`)} accessibilityRole="link">
<Avatar user={item} size={40} />
<View>
<Text style={styles.name}>{item.displayName ?? item.username}</Text>
<Text style={styles.handle}>@{item.username}</Text>
</View>
</Pressable>
)
}
/>
</SafeAreaView>
);
@@ -58,7 +93,7 @@ export default function SearchScreen() {
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
list: { padding: space.lg, gap: space.sm },
list: { padding: space.lg, gap: space.md },
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
handle: { color: colors.muted, fontSize: 13 },
+79 -42
View File
@@ -2,7 +2,7 @@ import { Link } from 'expo-router';
import { useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import useSWRInfinite from 'swr/infinite';
import type { FollowListDTO } from '@musicshare/shared';
import type { FollowListDTO, FollowListItemDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { useSession } from '@/lib/session';
@@ -10,17 +10,17 @@ import { colors, space } from '@/lib/theme';
import { Avatar } from './Avatar';
import { Body, Button } from './ui';
/** Followers or following, 50 at a time. On your own followers you can remove people. */
export function FollowList({ username, direction }: { username: string; direction: 'followers' | 'following' }) {
const { me } = useSession();
type Direction = 'followers' | 'following';
/** Followers or following, 50 at a time, with follow/unfollow/remove actions that refresh the list. */
export function useFollowList(username: string | undefined, direction: Direction) {
const { data, size, setSize, mutate, isLoading } = useSWRInfinite<FollowListDTO>(
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : [username, direction, prev?.nextCursor ?? null]),
([u, d, cursor]: [string, typeof direction, string | null]) => (d === 'followers' ? api.followers(u, cursor) : api.following(u, cursor)),
(i, prev: FollowListDTO | null) =>
!username || (i > 0 && !prev?.nextCursor) ? null : [username, direction, prev?.nextCursor ?? null],
([u, d, cursor]: [string, Direction, string | null]) => (d === 'followers' ? api.followers(u, cursor) : api.following(u, cursor)),
);
const [busy, setBusy] = useState<string | null>(null);
const users = data?.flatMap((p) => p.users) ?? [];
const more = Boolean(data?.at(-1)?.nextCursor);
const own = me?.username === username;
const act = async (target: string, fn: () => Promise<unknown>) => {
setBusy(target);
@@ -33,44 +33,81 @@ export function FollowList({ username, direction }: { username: string; directio
setBusy(null);
};
return {
users: data?.flatMap((p) => p.users) ?? [],
isLoading,
loadMore: () => more && setSize(size + 1),
refresh: () => mutate(),
busy,
act,
};
}
/** One person in a follow list: picture and name linking to their profile, plus Follow / Remove buttons. */
export function PersonRow({
user: u,
list,
removable,
}: {
user: FollowListItemDTO;
list: ReturnType<typeof useFollowList>;
/** On your own followers you can remove people. */
removable?: boolean;
}) {
const { me } = useSession();
return (
<FlatList
data={users}
keyExtractor={(u) => u.id}
contentContainerStyle={styles.list}
onEndReached={() => more && setSize(size + 1)}
ListEmptyComponent={
isLoading ? null : <Body muted>{direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.'}</Body>
}
renderItem={({ item: u }) => (
<View style={styles.row}>
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
<View style={styles.person}>
<Avatar user={u} size={40} />
<View style={{ flex: 1 }}>
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
<Text style={styles.handle}>@{u.username}</Text>
</View>
</View>
</Link>
{me && u.username !== me.username && (
<View style={styles.actions}>
{own && direction === 'followers' && (
<Button small title="Remove" disabled={busy === u.username} onPress={() => act(u.username, () => api.removeFollower(u.username))} />
)}
<Button
small
variant={u.isFollowing ? 'secondary' : 'primary'}
title={u.isFollowing ? 'Following' : 'Follow'}
disabled={busy === u.username}
onPress={() => act(u.username, () => (u.isFollowing ? api.unfollow(u.username) : api.follow(u.username)))}
/>
</View>
<View style={styles.row}>
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
<View style={styles.person}>
<Avatar user={u} size={40} />
<View style={{ flex: 1 }}>
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
<Text style={styles.handle}>@{u.username}</Text>
</View>
</View>
</Link>
{me && u.username !== me.username && (
<View style={styles.actions}>
{removable && (
<Button
small
title="Remove"
disabled={list.busy === u.username}
onPress={() => list.act(u.username, () => api.removeFollower(u.username))}
/>
)}
<Button
small
variant={u.isFollowing ? 'secondary' : 'primary'}
title={u.isFollowing ? 'Following' : 'Follow'}
disabled={list.busy === u.username}
onPress={() => list.act(u.username, () => (u.isFollowing ? api.unfollow(u.username) : api.follow(u.username)))}
/>
</View>
)}
</View>
);
}
export const emptyFollowText = (direction: Direction) =>
direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.';
/** Followers or following, 50 at a time. On your own followers you can remove people. */
export function FollowList({ username, direction }: { username: string; direction: Direction }) {
const { me } = useSession();
const list = useFollowList(username, direction);
const removable = me?.username === username && direction === 'followers';
return (
<FlatList
data={list.users}
keyExtractor={(u) => u.id}
contentContainerStyle={styles.list}
onEndReached={list.loadMore}
ListEmptyComponent={list.isLoading ? null : <Body muted>{emptyFollowText(direction)}</Body>}
renderItem={({ item }) => <PersonRow user={item} list={list} removable={removable} />}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { FollowList } from '@/components/FollowList';
import { UserSearch } from '@/components/UserSearch';
import { useMe } from '@/lib/api';
/** Friends: who you follow and who follows you, plus search to find new people. */
export default function FriendsPage() {
const { me, loggedOut } = useMe();
const [tab, setTab] = useState<'following' | 'followers'>('following');
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your friends.</p>;
if (!me) return null;
return (
<div className="stack">
<h1>Friends</h1>
<UserSearch />
<div className="row">
{(['following', 'followers'] as const).map((t) => (
<button key={t} className={`btn btn-small ${tab === t ? 'btn-primary' : ''}`} aria-pressed={tab === t} onClick={() => setTab(t)}>
{t === 'following' ? 'Following' : 'Followers'}
</button>
))}
</div>
<FollowList key={tab} username={me.username} direction={tab} />
</div>
);
}
+1 -3
View File
@@ -8,7 +8,6 @@ import { Avatar } from '@/components/Avatar';
import { Equalizer } from '@/components/Equalizer';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { SharingControls } from '@/components/SharingControls';
import { UserSearch } from '@/components/UserSearch';
import { api, fetcher, useMe } from '@/lib/api';
import { useRealtime } from '@/lib/useRealtime';
@@ -60,7 +59,6 @@ function Feed() {
return (
<div className="stack">
<UserSearch />
<SharingControls compact />
{mine?.nowPlaying && (
<section className="stack">
@@ -78,7 +76,7 @@ function Feed() {
(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>
<p className="muted">Nothing yet. <Link href="/friends">Find friends</Link>, 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. */}
<AnimatePresence initial={true}>
+10 -1
View File
@@ -5,13 +5,14 @@ import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { client, useMe } from '@/lib/api';
/** Top bar: logo and the three main places. On phones those move to a bottom tab bar. */
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
export function Nav() {
const { me } = useMe();
const path = usePathname();
const tabs = me
? [
{ href: '/', label: 'Feed', icon: <FeedIcon />, active: path === '/' },
{ href: '/friends', label: 'Friends', icon: <FriendsIcon />, active: path.startsWith('/friends') },
{ href: `/u/${me.username}`, label: 'Profile', icon: <ProfileIcon />, active: path.startsWith(`/u/${me.username}`) },
{ href: '/settings', label: 'Settings', icon: <SettingsIcon />, active: path.startsWith('/settings') || path === '/admin' },
]
@@ -89,6 +90,14 @@ const FeedIcon = () => (
<circle cx="18" cy="16" r="3" />
</svg>
);
const FriendsIcon = () => (
<svg {...icon} aria-hidden>
<circle cx="9" cy="8" r="3.5" />
<path d="M2 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
<path d="M16 4.5a3.5 3.5 0 0 1 0 7" />
<path d="M18 14c2.4.6 4 2.6 4 6" />
</svg>
);
const ProfileIcon = () => (
<svg {...icon} aria-hidden>
<circle cx="12" cy="8" r="4" />