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:
2026-10-15 13:32:14 -04:00
co-authored by Claude Opus 5.5
parent 31aed8dec4
commit 4402171f46
33 changed files with 2850 additions and 36 deletions
+1
View File
@@ -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}>
+195
View File
@@ -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 },
});
+5 -3
View File
@@ -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&apos;s wrong with @{username}&apos;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)} />
@@ -101,7 +101,7 @@ export function NowPlayingCard({
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
</Animated.View>
<ProgressBar np={np} compact={!expanded} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
{expanded && (
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
<ServiceLinks track={np.track} preferred={preferred} />
+19 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
import { router } from 'expo-router';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
@@ -11,16 +12,19 @@ import { colors, radius, space } from '@/lib/theme';
/**
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* opens the quick six, and "+" the full picker. One reaction per person.
* With `commentCount`, a 💬 chip opens the play's comments.
*/
export function Reactions({
listenId,
reactions,
ownerId,
commentCount,
style,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
commentCount?: number;
style?: ViewStyle;
}) {
const { me } = useSession();
@@ -31,7 +35,8 @@ export function Reactions({
const [pickerOpen, setPickerOpen] = useState(false);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const showComments = commentCount !== undefined && Boolean(me);
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
@@ -50,7 +55,7 @@ export function Reactions({
<View style={[styles.wrap, style]}>
<View style={styles.row}>
{shown.map((r) => (
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
<Animated.View key={r.emoji} entering={FadeIn.duration(150)}>
<Pressable
disabled={!canReact}
onPress={() => choose(r.emoji)}
@@ -73,6 +78,17 @@ export function Reactions({
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
</Pressable>
)}
{showComments && (
<Pressable
onPress={() => router.push(`/play/${listenId}`)}
accessibilityRole="button"
accessibilityLabel={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
hitSlop={6}
style={({ pressed }) => [styles.chip, pressed && { transform: [{ scale: 0.9 }] }]}>
<Text style={styles.emoji}>💬</Text>
{commentCount ? <Text style={styles.count}>{commentCount}</Text> : null}
</Pressable>
)}
</View>
{quickOpen && (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
+1 -1
View File
@@ -47,7 +47,7 @@ export function RecentPlays({
</View>
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
</Pressable>
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} style={styles.reactions} />
</View>
))}
{credits.map((s) => (