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,10 @@
|
||||
CREATE TABLE "reactions" (
|
||||
"listen_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"emoji" varchar(32) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "reactions_listen_id_user_id_pk" PRIMARY KEY("listen_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "reactions" ADD CONSTRAINT "reactions_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reactions" ADD CONSTRAINT "reactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1791305642128,
|
||||
"tag": "0012_email_verification",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1791308012441,
|
||||
"tag": "0013_reactions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -17,7 +17,7 @@ function assertTestTargets() {
|
||||
|
||||
export async function resetState() {
|
||||
assertTestTargets();
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets, email_verifications restart identity cascade`;
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets, email_verifications, reactions restart identity cascade`;
|
||||
await redis.flushdb();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyPollResult } from '../src/services/nowPlaying';
|
||||
import { bearer, createUser, follow, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
|
||||
|
||||
async function setup() {
|
||||
const alice = await createUser(app(), 'alice');
|
||||
const bob = await createUser(app(), 'bob');
|
||||
const carol = await createUser(app(), 'carol');
|
||||
await follow(app(), bob.token, 'alice');
|
||||
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
|
||||
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
|
||||
return { alice, bob, carol, listenId: feed[0].listenId as string };
|
||||
}
|
||||
const react = (token: string, listenId: string, emoji: string) =>
|
||||
app().inject({ method: 'PUT', url: `/listens/${listenId}/reaction`, headers: bearer(token), payload: { emoji } });
|
||||
|
||||
describe('reactions', () => {
|
||||
it('shows up on the feed and profile, grouped by emoji', async () => {
|
||||
const { alice, bob, carol, listenId } = await setup();
|
||||
expect(listenId).toBeTruthy();
|
||||
await react(bob.token, listenId, '🔥');
|
||||
const res = await react(carol.token, listenId, '🔥');
|
||||
expect(res.json()).toEqual([{ emoji: '🔥', count: 2, users: [expect.objectContaining({ username: 'carol' }), expect.objectContaining({ username: 'bob' })] }]);
|
||||
|
||||
const profile = (await app().inject({ url: '/users/alice', headers: bearer(alice.token) })).json();
|
||||
expect(profile.nowPlaying.reactions[0]).toMatchObject({ emoji: '🔥', count: 2 });
|
||||
});
|
||||
|
||||
it('one per person: a new emoji replaces the old one, and it can be removed', async () => {
|
||||
const { bob, listenId } = await setup();
|
||||
await react(bob.token, listenId, '🔥');
|
||||
expect((await react(bob.token, listenId, '😂')).json()).toEqual([expect.objectContaining({ emoji: '😂', count: 1 })]);
|
||||
const removed = await app().inject({ method: 'DELETE', url: `/listens/${listenId}/reaction`, headers: bearer(bob.token) });
|
||||
expect(removed.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows the play into recently played', async () => {
|
||||
const { alice, bob, listenId } = await setup();
|
||||
await react(bob.token, listenId, '❤️');
|
||||
await applyPollResult(alice.user.id, 'lastfm', song('Angel'));
|
||||
const profile = (await app().inject({ url: '/users/alice', headers: bearer(bob.token) })).json();
|
||||
expect(profile.nowPlaying.reactions).toEqual([]);
|
||||
expect(profile.recent[0]).toMatchObject({ listenId, reactions: [expect.objectContaining({ emoji: '❤️' })] });
|
||||
});
|
||||
|
||||
it('rejects anything but one emoji, your own plays, and blocked people', async () => {
|
||||
const { alice, bob, listenId } = await setup();
|
||||
expect((await react(bob.token, listenId, 'lol')).statusCode).toBe(400);
|
||||
expect((await react(bob.token, listenId, '🔥🔥')).statusCode).toBe(400);
|
||||
expect((await react(alice.token, listenId, '🔥')).statusCode).toBe(400);
|
||||
|
||||
await react(bob.token, listenId, '🔥');
|
||||
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
|
||||
expect((await react(bob.token, listenId, '😂')).statusCode).toBe(404);
|
||||
const profile = (await app().inject({ url: '/users/alice', headers: bearer(alice.token) })).json();
|
||||
expect(profile.nowPlaying.reactions).toEqual([]); // their earlier reaction went with the block
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user