Comments on plays
- Anyone who can see a play can read its comments; the player and the people they follow can write. The author, the player and admins can delete; comments can be reported (admins see the text). - Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its own setting. Reaction and comment notifications now open the play. - A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on the app (/play/[id]) and the website. Threads refresh every few seconds while open. - Blocking removes comments between the two people. Plays with comments survive history trimming. - Privacy page updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
This commit is contained in:
@@ -109,6 +109,7 @@ function AppNavigator() {
|
||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
||||
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
||||
<Stack.Screen name="play/[id]" options={{ title: 'Comments' }} />
|
||||
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Reactions } from '@/components/Reactions';
|
||||
import { TrackCard } from '@/components/TrackCard';
|
||||
import { Body, Card } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { tapHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
/** One play and its comments: where the 💬 chip and comment notifications lead. */
|
||||
export default function PlayScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { me } = useSession();
|
||||
// Polled while open so other people's comments show up without refreshing.
|
||||
const { data, error, mutate } = useSWR<CommentThreadDTO>(id ? `/listens/${id}/comments` : null, { refreshInterval: 5000 });
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body || !id) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const comment = await api.addComment(id, { body });
|
||||
tapHaptic();
|
||||
setDraft('');
|
||||
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
|
||||
} catch (err) {
|
||||
showError(err, "Couldn't post that");
|
||||
}
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const remove = (c: CommentDTO) =>
|
||||
Alert.alert('Delete this comment?', undefined, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await api.deleteComment(c.id);
|
||||
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
|
||||
} catch (err) {
|
||||
showError(err, "Couldn't delete it");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const options = (c: CommentDTO) => {
|
||||
const mine = c.user.id === me?.id;
|
||||
Alert.alert(c.user.displayName ?? c.user.username, undefined, [
|
||||
...(c.canDelete ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => remove(c) }] : []),
|
||||
...(!mine ? [{ text: 'Report', onPress: () => router.push(`/report/${c.user.username}?comment=${c.id}`) }] : []),
|
||||
{ text: 'Cancel', style: 'cancel' as const },
|
||||
]);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Stack.Screen options={{ title: '' }} />
|
||||
<Body muted>That play is gone.</Body>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.muted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const owner = data.owner.displayName ?? data.owner.username;
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<Stack.Screen options={{ title: 'Comments' }} />
|
||||
<AmbientBackground />
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={90}>
|
||||
<FlatList
|
||||
data={data.comments}
|
||||
keyExtractor={(c) => c.id}
|
||||
contentContainerStyle={styles.list}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.push(`/u/${data.owner.username}`)} style={styles.playedBy}>
|
||||
<Avatar user={data.owner} size={28} />
|
||||
<Text style={styles.muted}>
|
||||
<Text style={styles.name}>{owner}</Text> played this · {timeAgo(data.playedAt)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<TrackCard track={data.track} preferred={me?.preferredService} />
|
||||
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} style={{ marginTop: 0 }} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={<Body muted>{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</Body>}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onLongPress={() => options(item)} delayLongPress={300} style={styles.comment}>
|
||||
<Pressable onPress={() => router.push(`/u/${item.user.username}`)} hitSlop={4}>
|
||||
<Avatar user={item.user} size={34} />
|
||||
</Pressable>
|
||||
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
||||
<Text style={styles.muted}>
|
||||
<Text style={styles.name}>{item.user.displayName ?? item.user.username}</Text> · {timeAgo(item.createdAt)}
|
||||
</Text>
|
||||
<Text style={styles.body} selectable>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => options(item)} hitSlop={10} accessibilityLabel="Comment options">
|
||||
<Text style={styles.more}>⋯</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
{data.canComment ? (
|
||||
<View style={styles.composer}>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
|
||||
placeholderTextColor={colors.muted}
|
||||
multiline
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
style={styles.input}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={send}
|
||||
disabled={!draft.trim() || sending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Post comment"
|
||||
style={[styles.send, (!draft.trim() || sending) && { opacity: 0.4 }]}>
|
||||
{sending ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.sendText}>Post</Text>}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Card style={styles.closed}>
|
||||
<Body muted style={{ fontSize: 13 }}>
|
||||
Only people {owner} follows can comment on their songs.
|
||||
</Body>
|
||||
</Card>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
|
||||
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl },
|
||||
header: { gap: space.md, marginBottom: space.sm },
|
||||
playedBy: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||
comment: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||
name: { color: colors.text, fontWeight: '700' },
|
||||
muted: { color: colors.muted, fontSize: 13 },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||
more: { color: colors.muted, fontSize: 18, paddingHorizontal: space.xs },
|
||||
composer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: space.sm,
|
||||
paddingHorizontal: space.lg,
|
||||
paddingVertical: space.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
maxHeight: 120,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
|
||||
sendText: { color: '#fff', fontWeight: '700' },
|
||||
closed: { margin: space.lg, marginTop: 0 },
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
|
||||
export default function ReportScreen() {
|
||||
const { username } = useLocalSearchParams<{ username: string }>();
|
||||
// ?comment=<id> reports one comment of theirs rather than their account.
|
||||
const { username, comment } = useLocalSearchParams<{ username: string; comment?: string }>();
|
||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -18,7 +19,8 @@ export default function ReportScreen() {
|
||||
if (!reason) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.report(username, { reason, note: note.trim() || null });
|
||||
const input = { reason, note: note.trim() || null };
|
||||
await (comment ? api.reportComment(comment, input) : api.report(username, input));
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
showError(err, "Couldn't send the report");
|
||||
@@ -38,7 +40,7 @@ export default function ReportScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<Screen>
|
||||
<Body muted>What's wrong with @{username}'s account?</Body>
|
||||
<Body muted>{comment ? `What's wrong with @${username}'s comment?` : `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)} />
|
||||
|
||||
Reference in New Issue
Block a user