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
+1
View File
@@ -441,6 +441,7 @@ function Account() {
<Body muted style={styles.small}>
Signed in as @{me?.username} · {me?.plan === 'free' ? 'Free plan' : 'Plus'}
</Body>
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
<Row>
<Button small title="Log out" onPress={signOut} />
<Button small variant="danger" title="Delete account" onPress={confirmDelete} />
+5 -1
View File
@@ -91,7 +91,11 @@ function AppNavigator() {
</Stack.Protected>
<Stack.Protected guard={signedIn}>
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
<Stack.Screen name="u/[username]" options={{ title: '' }} />
<Stack.Screen name="u/[username]/index" options={{ title: '' }} />
<Stack.Screen name="u/[username]/followers" options={{ title: 'Followers' }} />
<Stack.Screen name="u/[username]/following" options={{ title: 'Following' }} />
<Stack.Screen name="report/[username]" options={{ title: 'Report', presentation: 'modal' }} />
<Stack.Screen name="blocked" options={{ title: 'Blocked accounts' }} />
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
+49
View File
@@ -0,0 +1,49 @@
import { Link } from 'expo-router';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { UserSummaryDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Body, Button } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { colors, space } from '@/lib/theme';
export default function BlockedScreen() {
const { data: blocked = [], mutate, isLoading } = useSWR<UserSummaryDTO[]>('/me/blocks');
const unblock = async (username: string) => {
try {
await api.unblock(username);
await mutate();
} catch (err) {
showError(err);
}
};
return (
<FlatList
data={blocked}
keyExtractor={(u) => u.id}
contentContainerStyle={styles.list}
ListEmptyComponent={isLoading ? null : <Body muted>You haven&apos;t blocked anyone. Block someone from the ⋯ menu on their profile.</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} />
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
</View>
</Link>
<Button small title="Unblock" onPress={() => unblock(u.username)} />
</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', flexShrink: 1 },
});
+52
View File
@@ -0,0 +1,52 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform } from 'react-native';
import { REPORT_REASONS, REPORT_REASON_LABELS, type ReportReason } from '@musicshare/shared';
import { Screen } from '@/components/Screen';
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
export default function ReportScreen() {
const { username } = useLocalSearchParams<{ username: string }>();
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const submit = async () => {
if (!reason) return;
setBusy(true);
try {
await api.report(username, { reason, note: note.trim() || null });
setSent(true);
} catch (err) {
showError(err, "Couldn't send the report");
}
setBusy(false);
};
if (sent) {
return (
<Screen>
<Title>Thanks for letting us know</Title>
<Body>We&apos;ll take a look. If they&apos;re bothering you, you can also block them from the ⋯ menu on their profile.</Body>
<Button variant="primary" title="Done" onPress={() => router.back()} />
</Screen>
);
}
return (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<Screen>
<Body muted>What&apos;s wrong with @{username}&apos;s account?</Body>
<Row>
{REPORT_REASONS.map((r) => (
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
))}
</Row>
<Field label="Anything else? (optional)" value={note} onChangeText={setNote} maxLength={500} multiline style={{ minHeight: 80 }} />
<Button variant="primary" title="Send report" disabled={!reason} loading={busy} onPress={submit} />
</Screen>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,12 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { FollowList } from '@/components/FollowList';
export default function FollowersScreen() {
const { username } = useLocalSearchParams<{ username: string }>();
return (
<>
<Stack.Screen options={{ title: 'Followers' }} />
<FollowList username={username} direction="followers" />
</>
);
}
@@ -0,0 +1,12 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { FollowList } from '@/components/FollowList';
export default function FollowingScreen() {
const { username } = useLocalSearchParams<{ username: string }>();
return (
<>
<Stack.Screen options={{ title: 'Following' }} />
<FollowList username={username} direction="following" />
</>
);
}