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