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
+71
View File
@@ -0,0 +1,71 @@
import { and, eq, inArray, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { isSingleEmoji } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { blockStatus } from '../services/blocks';
import { getReactions, publishNowPlaying } from '../services/nowPlaying';
const params = z.object({ id: z.uuid() });
const body = z.object({ emoji: z.string().refine(isSingleEmoji, 'Pick one emoji') });
const limit = { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } };
export async function reactionRoutes(app: FastifyInstance) {
/** The play, if the viewer may react to it: not their own, and no block either way. */
async function reactable(viewerId: string, listenId: string) {
const [row] = await db
.select({ listen: schema.listens, owner: schema.users })
.from(schema.listens)
.innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
.where(eq(schema.listens.id, listenId));
if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
if (row.owner.id === viewerId) throw new HttpError(400, "You can't react to your own songs");
const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
return row.listen;
}
async function afterChange(listen: { id: string; userId: string }) {
// Live update for the listener and their followers (it's ignored if this isn't the current song).
await publishNowPlaying(listen.userId);
return (await getReactions([listen.id])).get(listen.id) ?? [];
}
app.put('/listens/:id/reaction', limit, async (req) => {
const user = requireUser(req);
const { id } = params.parse(req.params);
const { emoji } = body.parse(req.body);
const listen = await reactable(user.id, id);
await db
.insert(schema.reactions)
.values({ listenId: id, userId: user.id, emoji })
.onConflictDoUpdate({ target: [schema.reactions.listenId, schema.reactions.userId], set: { emoji, createdAt: new Date() } });
return afterChange(listen);
});
app.delete('/listens/:id/reaction', limit, async (req) => {
const user = requireUser(req);
const { id } = params.parse(req.params);
const listen = await reactable(user.id, id);
await db.delete(schema.reactions).where(and(eq(schema.reactions.listenId, id), eq(schema.reactions.userId, user.id)));
return afterChange(listen);
});
}
/** When someone's blocked, their reactions on each other's plays go too. */
export async function removeReactionsBetween(a: string, b: string) {
const plays = db
.select({ id: schema.listens.id })
.from(schema.listens)
.where(or(eq(schema.listens.userId, a), eq(schema.listens.userId, b)));
await db
.delete(schema.reactions)
.where(
and(
inArray(schema.reactions.listenId, plays),
or(eq(schema.reactions.userId, a), eq(schema.reactions.userId, b)),
),
);
}
+2
View File
@@ -10,6 +10,7 @@ import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { toUserSummary } from '../services/nowPlaying';
import { removeReactionsBetween } from './reactions';
async function findUser(username: string) {
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
@@ -46,6 +47,7 @@ export async function safetyRoutes(app: FastifyInstance) {
),
);
});
await removeReactionsBetween(me.id, them.id);
await followsChanged(me.id, them.id);
return { ok: true };
});