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
+26
View File
@@ -102,6 +102,32 @@ input:focus, textarea:focus, select:focus {
.share-link { font: inherit; cursor: pointer; color: var(--text); }
.song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; }
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.07); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.13); }
.reaction:active:not(:disabled) { transform: scale(0.9); }
.reaction:disabled { cursor: default; }
.reaction.mine { border-color: rgba(123, 47, 247, 0.7); background: rgba(123, 47, 247, 0.28); }
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
.reaction-add { color: var(--muted); font-size: 1rem; }
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
.reaction-quick button:hover { transform: scale(1.25); }
.reaction-quick button.mine { background: rgba(123, 47, 247, 0.35); }
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
.emoji-tabs { display: flex; gap: 0.15rem; margin-bottom: 0.5rem; overflow-x: auto; }
.emoji-tabs button { all: unset; cursor: pointer; font-size: 1.25rem; padding: 0.25rem 0.4rem; border-radius: 0.5rem; opacity: 0.6; }
.emoji-tabs button[aria-selected='true'] { opacity: 1; background: rgba(255, 255, 255, 0.1); }
.emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr)); gap: 0.1rem; max-height: 18rem; overflow-y: auto; }
.emoji-grid button { all: unset; cursor: pointer; font-size: 1.55rem; text-align: center; padding: 0.2rem; border-radius: 0.5rem; }
.emoji-grid button:hover { background: rgba(255, 255, 255, 0.1); }
@media (prefers-reduced-motion: reduce) { .reaction-quick { animation: none; } }
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; }
@@ -138,7 +138,7 @@ export function ProfileClient() {
<p className="muted">Nothing playing right now.</p>
)}
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} ownerId={user.id} />
</div>
);
}
@@ -6,6 +6,7 @@ import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } fr
import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar';
import { Reactions } from './Reactions';
import { TrackCard } from './TrackCard';
export function NowPlayingCard({
@@ -85,6 +86,7 @@ export function NowPlayingCard({
>
<TrackCard track={np.track} preferred={preferred} />
<ProgressBar np={np} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
</motion.div>
</AnimatePresence>
</article>
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { client, useMe } from '@/lib/api';
import { Modal } from './ProfileMenu';
/**
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* offers the quick six, and "More" opens the full picker. One reaction per person.
*/
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
const { me } = useMe();
// 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 [error, setError] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!quickOpen) return;
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setQuickOpen(false);
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [quickOpen]);
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);
setError(null);
try {
const value = emoji === mine ? await client.unreact(listenId) : await client.react(listenId, emoji);
setLocal({ from: reactions, value });
} catch (err) {
setError((err as Error).message);
}
};
return (
<div className="reactions" ref={ref}>
{shown.map((r) => (
<button
key={r.emoji}
type="button"
className={`reaction ${r.emoji === mine ? 'mine' : ''}`}
disabled={!canReact}
onClick={() => choose(r.emoji)}
title={names(r)}
aria-label={`${r.emoji} ${r.count}: ${names(r)}`}
>
<span className="reaction-emoji">{r.emoji}</span>
{r.count > 1 && <span className="reaction-count">{r.count}</span>}
</button>
))}
{canReact && (
<span className="reaction-add-wrap">
<button type="button" className="reaction reaction-add" onClick={() => setQuickOpen((v) => !v)} aria-label="Add a reaction" aria-expanded={quickOpen}>
☺<span aria-hidden>+</span>
</button>
{quickOpen && (
<div className="reaction-quick" role="menu">
{QUICK_REACTIONS.map((e) => (
<button key={e} type="button" role="menuitem" className={e === mine ? 'mine' : ''} onClick={() => choose(e)}>
{e}
</button>
))}
<button type="button" role="menuitem" className="reaction-more" onClick={() => (setQuickOpen(false), setPickerOpen(true))}>
More…
</button>
</div>
)}
</span>
)}
{error && <span className="error">{error}</span>}
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
</div>
);
}
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. */
export function EmojiPicker({ onPick, onClose }: { onPick: (emoji: string) => void; onClose: () => void }) {
const [query, setQuery] = useState('');
const [group, setGroup] = useState(0);
const results = query.trim() ? searchEmoji(query) : EMOJI_PICKER_GROUPS[group]!.emojis;
return (
<Modal title="React" onClose={onClose}>
<input className="emoji-search" placeholder="Search emoji" value={query} onChange={(e) => setQuery(e.target.value)} autoFocus />
{!query.trim() && (
<div className="emoji-tabs" role="tablist">
{EMOJI_PICKER_GROUPS.map((g, i) => (
<button key={g.name} type="button" role="tab" aria-selected={i === group} title={g.name} onClick={() => setGroup(i)}>
{g.emojis[0]?.emoji}
</button>
))}
</div>
)}
<div className="emoji-grid">
{results.map((e) => (
<button key={e.emoji} type="button" title={e.name} aria-label={e.name} onClick={() => onPick(e.emoji)}>
{e.emoji}
</button>
))}
{results.length === 0 && <p className="muted">No emoji match “{query}”.</p>}
</div>
</Modal>
);
}
+6 -1
View File
@@ -1,13 +1,17 @@
'use client';
import { Reactions } from './Reactions';
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
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'];
}) {
@@ -30,7 +34,7 @@ export function RecentPlays({
</>
);
return (
<li key={`${p.playedAt}-${i}`}>
<li key={p.listenId ?? `${p.playedAt}-${i}`}>
{target ? (
<a
className="recent-row"
@@ -44,6 +48,7 @@ export function RecentPlays({
) : (
<div className="recent-row">{row}</div>
)}
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
</li>
);
})}