Safety screens: followers, blocking, reporting, admin
CI / check (push) Canceled after 0s

Web and mobile:
- Follower/following lists (tap the counts), with Follow buttons and
  "Remove" on your own followers.
- Profile ⋯ menu: block (with confirmation) / unblock, and report with a
  reason and optional note. "Follows you" label, "Follow back", and a
  banner on profiles you've blocked.
- Blocked accounts list in Settings, with unblock.
Web only: /admin for the admin account: stats, open/resolved reports,
resolve, suspend/unsuspend.

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 12:53:28 -04:00
co-authored by Claude Opus 5.5
parent 13df568348
commit 45a7bd190a
21 changed files with 741 additions and 21 deletions
+85
View File
@@ -0,0 +1,85 @@
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 { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { useSession } from '@/lib/session';
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();
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)),
);
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);
try {
await fn();
await mutate();
} catch (err) {
showError(err);
}
setBusy(null);
};
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>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.md },
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
name: { color: colors.text, fontSize: 15, fontWeight: '600' },
handle: { color: colors.muted, fontSize: 13 },
actions: { flexDirection: 'row', gap: space.sm },
});
+58 -9
View File
@@ -1,6 +1,6 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { ProfileDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
@@ -49,6 +49,28 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
}
if (!profile) return <Screen edges={edges} />;
const { user } = profile;
const name = user.displayName ?? user.username;
const openMenu = () =>
Alert.alert(name, undefined, [
profile.isBlocked
? { text: `Unblock ${name}`, onPress: () => act(() => api.unblock(username)) }
: {
text: `Block ${name}`,
style: 'destructive',
onPress: () =>
Alert.alert(
`Block ${name}?`,
"They won't be able to find you, see your profile or what you're playing, and you'll stop following each other. They aren't told.",
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Block', style: 'destructive', onPress: () => act(() => api.block(username)) },
],
),
},
{ text: `Report ${name}`, style: 'destructive', onPress: () => router.push(`/report/${username}`) },
{ text: 'Cancel', style: 'cancel' },
]);
return (
<Screen
@@ -64,8 +86,21 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
<View style={{ flex: 1, gap: 2 }}>
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
<Text style={styles.meta}>
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
@{user.username}
{profile.followsYou && !profile.isSelf ? ' · Follows you' : ''}
</Text>
<View style={styles.counts}>
<Pressable onPress={() => router.push(`/u/${user.username}/followers`)} hitSlop={6}>
<Text style={styles.meta}>
<Text style={styles.count}>{profile.followerCount}</Text> {profile.followerCount === 1 ? 'follower' : 'followers'}
</Text>
</Pressable>
<Pressable onPress={() => router.push(`/u/${user.username}/following`)} hitSlop={6}>
<Text style={styles.meta}>
<Text style={styles.count}>{profile.followingCount}</Text> following
</Text>
</Pressable>
</View>
</View>
</View>
{user.bio && <Body>{user.bio}</Body>}
@@ -75,13 +110,15 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
) : (
<>
<Button
small
variant={profile.isFollowing ? 'secondary' : 'primary'}
title={profile.isFollowing ? 'Following' : 'Follow'}
disabled={busy}
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
/>
{!profile.isBlocked && (
<Button
small
variant={profile.isFollowing ? 'secondary' : 'primary'}
title={profile.isFollowing ? 'Following' : profile.followsYou ? 'Follow back' : 'Follow'}
disabled={busy}
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
/>
)}
{profile.isFollowing && (
<Button
small
@@ -90,10 +127,20 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
/>
)}
<Button small title="⋯" accessibilityLabel="More options" onPress={openMenu} />
</>
)}
</Row>
{profile.isBlocked && (
<Card>
<Body muted>
You blocked @{user.username}. They can&apos;t see your profile or what you play, and you won&apos;t see theirs. Unblock
them from the ⋯ menu.
</Body>
</Card>
)}
{profile.featured && (
<Card>
<Heading>Featured song</Heading>
@@ -116,6 +163,8 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
meta: { color: colors.muted, fontSize: 13 },
counts: { flexDirection: 'row', gap: space.lg, marginTop: 2 },
count: { color: colors.text, fontWeight: '700' },
quote: {
color: colors.text,
fontStyle: 'italic',