Emoji reactions on songs
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
This commit is contained in:
2026-10-06 13:39:26 -04:00
co-authored by Claude Opus 5.5
parent edc0c7b999
commit d2edad4b0d
28 changed files with 2200 additions and 30 deletions
@@ -18,6 +18,7 @@ import { colors, radius, space } from '@/lib/theme';
import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar';
import { Reactions } from './Reactions';
import { TrackCard } from './TrackCard';
export function NowPlayingCard({
@@ -95,6 +96,7 @@ export function NowPlayingCard({
<TrackCard track={np.track} preferred={preferred} />
</Animated.View>
<ProgressBar np={np} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
</View>
);
}
+1 -1
View File
@@ -155,7 +155,7 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
<Body muted>Nothing playing right now.</Body>
)}
<RecentPlays plays={profile.recent} preferred={me?.preferredService} attribution={profile.attribution} />
<RecentPlays plays={profile.recent} preferred={me?.preferredService} attribution={profile.attribution} ownerId={user.id} />
</Screen>
);
}
+198
View File
@@ -0,0 +1,198 @@
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 },
});
+29 -22
View File
@@ -4,13 +4,17 @@ import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingSe
import { showError } from '@/lib/errors';
import { colors, radius, space } from '@/lib/theme';
import { Card, Heading } from './ui';
import { Reactions } from './Reactions';
export function RecentPlays({
plays,
preferred,
attribution = {},
ownerId,
}: {
plays: RecentPlayDTO[];
/** Whose plays these are (they can't react to their own). */
ownerId: string;
preferred: StreamingService | null | undefined;
attribution?: ProfileDTO['attribution'];
}) {
@@ -20,29 +24,31 @@ export function RecentPlays({
<Card style={{ gap: 0 }}>
<Heading style={{ marginBottom: space.sm }}>Recently played</Heading>
{plays.map((p, i) => (
<Pressable
key={`${p.playedAt}-${i}`}
disabled={!preferred}
accessibilityRole={preferred ? 'link' : undefined}
onPress={() => preferred && Linking.openURL(openUrl(p.track, preferred).url).catch((e) => showError(e, "Couldn't open link"))}
style={({ pressed }) => [styles.row, i > 0 && styles.divider, pressed && { opacity: 0.6 }]}>
{p.track.artworkUrl ? (
<Image source={p.track.artworkUrl} style={styles.art} />
) : (
<View style={[styles.art, styles.artEmpty]}>
<Text style={{ color: colors.muted }}>♪</Text>
<View key={p.listenId ?? `${p.playedAt}-${i}`} style={i > 0 && styles.divider}>
<Pressable
disabled={!preferred}
accessibilityRole={preferred ? 'link' : undefined}
onPress={() => preferred && Linking.openURL(openUrl(p.track, preferred).url).catch((e) => showError(e, "Couldn't open link"))}
style={({ pressed }) => [styles.row, pressed && { opacity: 0.6 }]}>
{p.track.artworkUrl ? (
<Image source={p.track.artworkUrl} style={styles.art} />
) : (
<View style={[styles.art, styles.artEmpty]}>
<Text style={{ color: colors.muted }}>♪</Text>
</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={1}>
{p.track.title}
</Text>
<Text style={styles.artist} numberOfLines={1}>
{p.track.artist}
</Text>
</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={1}>
{p.track.title}
</Text>
<Text style={styles.artist} numberOfLines={1}>
{p.track.artist}
</Text>
</View>
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
</Pressable>
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
</Pressable>
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
</View>
))}
{credits.map((s) => (
<Text key={s} style={styles.credit} onPress={() => Linking.openURL(attribution[s]!)}>
@@ -61,5 +67,6 @@ const styles = StyleSheet.create({
title: { color: colors.text, fontWeight: '600', fontSize: 14 },
artist: { color: colors.muted, fontSize: 13 },
time: { color: colors.muted, fontSize: 12 },
reactions: { marginLeft: 40 + space.md, marginTop: -space.sm, marginBottom: space.xs },
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm },
});