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:
@@ -1,37 +1,56 @@
|
|||||||
import { router } from 'expo-router';
|
import { router, useFocusEffect } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
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 type { UserSummaryDTO } from '@musicshare/shared';
|
import type { FollowListItemDTO, UserSummaryDTO } from '@musicshare/shared';
|
||||||
import { AmbientBackground } from '@/components/AmbientBackground';
|
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||||
import { Avatar } from '@/components/Avatar';
|
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 { useDebounced } from '@/lib/useDebounced';
|
||||||
import { colors, space } from '@/lib/theme';
|
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 [q, setQ] = useState('');
|
||||||
|
const [tab, setTab] = useState<'following' | 'followers'>('following');
|
||||||
const query = useDebounced(q.trim(), 250);
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<FlatList
|
<FlatList
|
||||||
data={query.length >= 2 ? (data ?? []) : []}
|
data={rows}
|
||||||
keyExtractor={(u) => u.id}
|
keyExtractor={(u) => u.id}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReached={searching ? undefined : list.loadMore}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={{ gap: space.md }}>
|
<View style={{ gap: space.md }}>
|
||||||
<Title>Find friends</Title>
|
<Title>Friends</Title>
|
||||||
<Row>
|
<Row>
|
||||||
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
|
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
|
||||||
<Button small title="My code" onPress={() => router.push('/qr')} />
|
<Button small title="My code" onPress={() => router.push('/qr')} />
|
||||||
</Row>
|
</Row>
|
||||||
<Field
|
<Field
|
||||||
placeholder="Search by name or username"
|
placeholder="Find people by name or username"
|
||||||
value={q}
|
value={q}
|
||||||
onChangeText={setQ}
|
onChangeText={setQ}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -39,18 +58,34 @@ export default function SearchScreen() {
|
|||||||
returnKeyType="search"
|
returnKeyType="search"
|
||||||
clearButtonMode="while-editing"
|
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>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={query.length >= 2 && data ? <Body muted>No one found.</Body> : null}
|
ListEmptyComponent={
|
||||||
renderItem={({ item }) => (
|
searching ? (
|
||||||
<Pressable style={styles.row} onPress={() => router.push(`/u/${item.username}`)} accessibilityRole="link">
|
found ? <Body muted>No one found.</Body> : null
|
||||||
<Avatar user={item} size={40} />
|
) : list.isLoading ? null : (
|
||||||
<View>
|
<Body muted>{emptyFollowText(tab)}</Body>
|
||||||
<Text style={styles.name}>{item.displayName ?? item.username}</Text>
|
)
|
||||||
<Text style={styles.handle}>@{item.username}</Text>
|
}
|
||||||
</View>
|
renderItem={({ item }) =>
|
||||||
</Pressable>
|
'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>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -58,7 +93,7 @@ export default function SearchScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safe: { flex: 1, backgroundColor: colors.bg },
|
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 },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
||||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
||||||
handle: { color: colors.muted, fontSize: 13 },
|
handle: { color: colors.muted, fontSize: 13 },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from 'expo-router';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||||
import useSWRInfinite from 'swr/infinite';
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import type { FollowListDTO } from '@musicshare/shared';
|
import type { FollowListDTO, FollowListItemDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
@@ -10,17 +10,17 @@ import { colors, space } from '@/lib/theme';
|
|||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { Body, Button } from './ui';
|
import { Body, Button } from './ui';
|
||||||
|
|
||||||
/** Followers or following, 50 at a time. On your own followers you can remove people. */
|
type Direction = 'followers' | 'following';
|
||||||
export function FollowList({ username, direction }: { username: string; direction: 'followers' | 'following' }) {
|
|
||||||
const { me } = useSession();
|
/** 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>(
|
const { data, size, setSize, mutate, isLoading } = useSWRInfinite<FollowListDTO>(
|
||||||
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : [username, direction, prev?.nextCursor ?? null]),
|
(i, prev: FollowListDTO | null) =>
|
||||||
([u, d, cursor]: [string, typeof direction, string | null]) => (d === 'followers' ? api.followers(u, cursor) : api.following(u, cursor)),
|
!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 [busy, setBusy] = useState<string | null>(null);
|
||||||
const users = data?.flatMap((p) => p.users) ?? [];
|
|
||||||
const more = Boolean(data?.at(-1)?.nextCursor);
|
const more = Boolean(data?.at(-1)?.nextCursor);
|
||||||
const own = me?.username === username;
|
|
||||||
|
|
||||||
const act = async (target: string, fn: () => Promise<unknown>) => {
|
const act = async (target: string, fn: () => Promise<unknown>) => {
|
||||||
setBusy(target);
|
setBusy(target);
|
||||||
@@ -33,44 +33,81 @@ export function FollowList({ username, direction }: { username: string; directio
|
|||||||
setBusy(null);
|
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 (
|
return (
|
||||||
<FlatList
|
<View style={styles.row}>
|
||||||
data={users}
|
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
|
||||||
keyExtractor={(u) => u.id}
|
<View style={styles.person}>
|
||||||
contentContainerStyle={styles.list}
|
<Avatar user={u} size={40} />
|
||||||
onEndReached={() => more && setSize(size + 1)}
|
<View style={{ flex: 1 }}>
|
||||||
ListEmptyComponent={
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
isLoading ? null : <Body muted>{direction === 'followers' ? 'No followers yet.' : 'Not following anyone yet.'}</Body>
|
{u.displayName ?? u.username}
|
||||||
}
|
</Text>
|
||||||
renderItem={({ item: u }) => (
|
<Text style={styles.handle}>@{u.username}</Text>
|
||||||
<View style={styles.row}>
|
</View>
|
||||||
<Link href={`/u/${u.username}`} style={{ flex: 1 }}>
|
</View>
|
||||||
<View style={styles.person}>
|
</Link>
|
||||||
<Avatar user={u} size={40} />
|
{me && u.username !== me.username && (
|
||||||
<View style={{ flex: 1 }}>
|
<View style={styles.actions}>
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
{removable && (
|
||||||
{u.displayName ?? u.username}
|
<Button
|
||||||
</Text>
|
small
|
||||||
<Text style={styles.handle}>@{u.username}</Text>
|
title="Remove"
|
||||||
</View>
|
disabled={list.busy === u.username}
|
||||||
</View>
|
onPress={() => list.act(u.username, () => api.removeFollower(u.username))}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
</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} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ 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';
|
||||||
import { UserSearch } from '@/components/UserSearch';
|
|
||||||
import { api, fetcher, useMe } from '@/lib/api';
|
import { api, fetcher, useMe } from '@/lib/api';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
|
|
||||||
@@ -60,7 +59,6 @@ function Feed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<UserSearch />
|
|
||||||
<SharingControls compact />
|
<SharingControls compact />
|
||||||
{mine?.nowPlaying && (
|
{mine?.nowPlaying && (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -78,7 +76,7 @@ function Feed() {
|
|||||||
(following?.users.length ? (
|
(following?.users.length ? (
|
||||||
<p className="muted">Nobody you follow has played anything yet. Their music will show up here live.</p>
|
<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. */}
|
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
||||||
<AnimatePresence initial={true}>
|
<AnimatePresence initial={true}>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { client, useMe } from '@/lib/api';
|
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() {
|
export function Nav() {
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const tabs = me
|
const tabs = me
|
||||||
? [
|
? [
|
||||||
{ href: '/', label: 'Feed', icon: <FeedIcon />, active: path === '/' },
|
{ 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: `/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' },
|
{ 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" />
|
<circle cx="18" cy="16" r="3" />
|
||||||
</svg>
|
</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 = () => (
|
const ProfileIcon = () => (
|
||||||
<svg {...icon} aria-hidden>
|
<svg {...icon} aria-hidden>
|
||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user