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 ( {shown.map((r) => ( 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 }] }]}> {r.emoji} {r.count > 1 && {r.count}} ))} {canReact && ( setQuickOpen((v) => !v)} accessibilityRole="button" accessibilityLabel="Add a reaction" hitSlop={6} style={[styles.chip, quickOpen && styles.mine]}> ☺ + )} {quickOpen && ( {QUICK_REACTIONS.map((e) => ( choose(e)} hitSlop={4} style={[styles.quickItem, e === mine && styles.mine]} accessibilityLabel={`React ${e}`}> {e} ))} (setQuickOpen(false), setPickerOpen(true))} hitSlop={4} style={styles.quickItem} accessibilityLabel="More emoji"> + )} setPickerOpen(false)} /> ); } 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 ( {!query.trim() && ( {EMOJI_PICKER_GROUPS.map((g, i) => ( setGroup(i)} accessibilityLabel={g.name} style={[styles.tab, i === group && styles.tabOn]}> {g.emojis[0]?.emoji} ))} )} e.emoji} initialNumToRender={64} keyboardShouldPersistTaps="handled" renderItem={({ item }) => ( onPick(item.emoji)} style={styles.cell} accessibilityLabel={item.name}> {item.emoji} )} ListEmptyComponent={No emoji match “{query}”.} /> ); } 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 }, });