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:
@@ -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} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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'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 },
|
||||
});
|
||||
@@ -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'll take a look. If they'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's wrong with @{username}'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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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't see your profile or what you play, and you won'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',
|
||||
|
||||
Reference in New Issue
Block a user