Files
musicshare/apps/mobile/src/components/Reactions.tsx
T
chrisandClaude Opus 5.5 d2edad4b0d
CI / check (push) Canceled after 0s
Emoji reactions on songs
React to a friend's current song or any of their recent plays: tap an
emoji to add yours (or take it back), "☺+" for the quick six, and a full
picker with search and categories (Unicode emoji up to 13.1, generated
into the shared package so web and mobile match). One reaction per person
per play; a new one replaces the old. Chips show counts and, on hover/
long label, who reacted; yours is highlighted. Listeners and followers get
the update live. Not on your own plays, not across a block (blocking also
clears reactions between the two people).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-06 13:39:26 -04:00

199 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
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';
/**
* 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.
*/
export function Reactions({
listenId,
reactions,
ownerId,
style,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
style?: ViewStyle;
}) {
const { me } = useSession();
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
const shown = local && local.from === reactions ? local.value : reactions;
const [quickOpen, setQuickOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
setQuickOpen(false);
setPickerOpen(false);
tapHaptic();
try {
const value = emoji === mine ? await api.unreact(listenId) : await api.react(listenId, emoji);
setLocal({ from: reactions, value });
} catch (err) {
showError(err, "Couldn't react");
}
};
return (
<View style={[styles.wrap, style]}>
<View style={styles.row}>
{shown.map((r) => (
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
<Pressable
disabled={!canReact}
onPress={() => choose(r.emoji)}
accessibilityRole="button"
accessibilityLabel={`${r.emoji} ${r.count}: ${names(r)}`}
style={({ pressed }) => [styles.chip, r.emoji === mine && styles.mine, pressed && { transform: [{ scale: 0.9 }] }]}>
<Text style={styles.emoji}>{r.emoji}</Text>
{r.count > 1 && <Text style={styles.count}>{r.count}</Text>}
</Pressable>
</Animated.View>
))}
{canReact && (
<Pressable
onPress={() => setQuickOpen((v) => !v)}
accessibilityRole="button"
accessibilityLabel="Add a reaction"
hitSlop={6}
style={[styles.chip, quickOpen && styles.mine]}>
<Text style={[styles.emoji, { color: colors.muted }]}>☺</Text>
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
</Pressable>
)}
</View>
{quickOpen && (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
{QUICK_REACTIONS.map((e) => (
<Pressable key={e} onPress={() => choose(e)} hitSlop={4} style={[styles.quickItem, e === mine && styles.mine]} accessibilityLabel={`React ${e}`}>
<Text style={styles.quickEmoji}>{e}</Text>
</Pressable>
))}
<Pressable onPress={() => (setQuickOpen(false), setPickerOpen(true))} hitSlop={4} style={styles.quickItem} accessibilityLabel="More emoji">
<Text style={[styles.quickEmoji, { color: colors.muted, fontSize: 22 }]}>+</Text>
</Pressable>
</Animated.View>
)}
<EmojiPicker visible={pickerOpen} onPick={choose} onClose={() => setPickerOpen(false)} />
</View>
);
}
function names(r: ReactionDTO) {
const list = r.users.map((u) => u.displayName ?? u.username);
const extra = r.count - list.length;
return extra > 0 ? `${list.join(', ')} and ${extra} more` : list.join(', ');
}
/** Every emoji, by category, with search, in a bottom sheet. */
function EmojiPicker({ visible, onPick, onClose }: { visible: boolean; onPick: (emoji: string) => void; onClose: () => void }) {
const [query, setQuery] = useState('');
const [group, setGroup] = useState(0);
const results = query.trim() ? searchEmoji(query, 120) : EMOJI_PICKER_GROUPS[group]!.emojis;
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel="Close" />
<View style={styles.sheet}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search emoji"
placeholderTextColor={colors.muted}
style={styles.search}
autoCorrect={false}
/>
{!query.trim() && (
<View style={styles.tabs}>
{EMOJI_PICKER_GROUPS.map((g, i) => (
<Pressable key={g.name} onPress={() => setGroup(i)} accessibilityLabel={g.name} style={[styles.tab, i === group && styles.tabOn]}>
<Text style={styles.tabEmoji}>{g.emojis[0]?.emoji}</Text>
</Pressable>
))}
</View>
)}
<FlatList
data={results}
key={query.trim() ? 'search' : `group-${group}`}
numColumns={8}
keyExtractor={(e) => e.emoji}
initialNumToRender={64}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<Pressable onPress={() => onPick(item.emoji)} style={styles.cell} accessibilityLabel={item.name}>
<Text style={styles.cellEmoji}>{item.emoji}</Text>
</Pressable>
)}
ListEmptyComponent={<Text style={{ color: colors.muted, padding: space.lg }}>No emoji match “{query}”.</Text>}
/>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
wrap: { marginTop: space.md, gap: space.sm },
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
paddingHorizontal: 9,
paddingVertical: 3,
borderRadius: radius.pill,
backgroundColor: 'rgba(255,255,255,0.07)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.16)',
},
mine: { backgroundColor: 'rgba(123,47,247,0.3)', borderColor: 'rgba(123,47,247,0.75)' },
emoji: { fontSize: 16 },
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'] },
quick: {
flexDirection: 'row',
alignSelf: 'flex-start',
alignItems: 'center',
gap: 2,
padding: 4,
borderRadius: radius.pill,
backgroundColor: '#1a1535',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.16)',
},
quickItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
quickEmoji: { fontSize: 26 },
backdrop: { flex: 1, backgroundColor: 'rgba(5,3,15,0.6)' },
sheet: {
height: '55%',
backgroundColor: '#15112c',
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
padding: space.md,
gap: space.sm,
},
search: {
color: colors.text,
backgroundColor: 'rgba(255,255,255,0.07)',
borderRadius: radius.md,
paddingHorizontal: space.md,
paddingVertical: 10,
fontSize: 15,
},
tabs: { flexDirection: 'row', justifyContent: 'space-between' },
tab: { padding: 6, borderRadius: radius.sm, opacity: 0.55 },
tabOn: { opacity: 1, backgroundColor: 'rgba(255,255,255,0.1)' },
tabEmoji: { fontSize: 20 },
cell: { flex: 1 / 8, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
cellEmoji: { fontSize: 28 },
});