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 },
});