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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user