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
+2
View File
@@ -17,6 +17,7 @@ import { avatarRoutes } from './routes/avatars';
import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me';
import { reactionRoutes } from './routes/reactions';
import { realtimeRoutes } from './routes/realtime';
import { safetyRoutes } from './routes/safety';
import { trackRoutes } from './routes/tracks';
@@ -74,6 +75,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(safetyRoutes);
await app.register(adminRoutes);
await app.register(trackRoutes);
await app.register(reactionRoutes);
await app.register(connectionRoutes);
await app.register(deviceRoutes);
await app.register(realtimeRoutes);
+16
View File
@@ -242,6 +242,22 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`),
});
/** One emoji per person per play. Deleted with the play (only the latest 50 plays are kept). */
export const reactions = pgTable(
'reactions',
{
listenId: uuid('listen_id')
.notNull()
.references(() => listens.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
emoji: varchar('emoji', { length: 32 }).notNull(),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
);
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable(
'email_verifications',
+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 };
});
+49 -4
View File
@@ -1,5 +1,5 @@
import { and, desc, eq, inArray, lt } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { publicAvatarUrl } from '../lib/avatars';
@@ -15,6 +15,31 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
avatarUrl: publicAvatarUrl(u.avatarUrl),
});
const REACTORS_SHOWN = 20;
/** Reactions on each play, most popular emoji first; who reacted, newest first. */
export async function getReactions(listenIds: string[]): Promise<Map<string, ReactionDTO[]>> {
const result = new Map<string, ReactionDTO[]>();
if (listenIds.length === 0) return result;
const rows = await db
.select({ listenId: schema.reactions.listenId, emoji: schema.reactions.emoji, user: schema.users })
.from(schema.reactions)
.innerJoin(schema.users, eq(schema.users.id, schema.reactions.userId))
.where(and(inArray(schema.reactions.listenId, listenIds), isNull(schema.users.suspendedAt)))
.orderBy(desc(schema.reactions.createdAt));
const byListen = new Map<string, Map<string, ReactionDTO>>();
for (const r of rows) {
const emojis = byListen.get(r.listenId) ?? new Map<string, ReactionDTO>();
byListen.set(r.listenId, emojis);
const entry = emojis.get(r.emoji) ?? { emoji: r.emoji, count: 0, users: [] };
emojis.set(r.emoji, entry);
entry.count++;
if (entry.users.length < REACTORS_SHOWN) entry.users.push(toUserSummary(r.user));
}
for (const [id, emojis] of byListen) result.set(id, [...emojis.values()].sort((a, b) => b.count - a.count));
return result;
}
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
@@ -27,6 +52,18 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
.innerJoin(schema.users, eq(schema.users.id, schema.nowPlaying.userId))
.where(inArray(schema.nowPlaying.userId, userIds));
const tracks = await getTrackDTOs(rows.map((r) => r.np.trackId));
// The play that's showing is the user's latest listen, if it's the same song.
const latest = await db
.selectDistinctOn([schema.listens.userId], { userId: schema.listens.userId, id: schema.listens.id, trackId: schema.listens.trackId })
.from(schema.listens)
.where(inArray(schema.listens.userId, userIds))
.orderBy(schema.listens.userId, desc(schema.listens.startedAt));
const listenIds = new Map(latest.map((l) => [l.userId, l]));
const listenFor = (np: { userId: string; trackId: string }) => {
const l = listenIds.get(np.userId);
return l && l.trackId === np.trackId ? l.id : null;
};
const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
const now = Date.now();
return rows
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
@@ -39,6 +76,8 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
sourceUrl: np.sourceUrl,
progressMs: positionNow(np, track.durationMs, now),
updatedAt: np.updatedAt.toISOString(),
listenId: listenFor(np),
reactions: reactions.get(listenFor(np) ?? '') ?? [],
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
@@ -83,8 +122,14 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
// Don't repeat the track that's playing right now.
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
const tracks = await getTrackDTOs(plays.map((p) => p.trackId));
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
return plays.map((p) => ({
listenId: p.id,
track: tracks.get(p.trackId)!,
source: p.source,
playedAt: p.startedAt.toISOString(),
reactions: reactions.get(p.id) ?? [],
}));
}
/** Paused by the user, or suspended by a moderator: nothing new is shared either way. */