CI / check (push) Canceled after 0s
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
199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
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 },
|
||
});
|