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)} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user