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
+1
View File
@@ -16,6 +16,7 @@
"zod": "^4.6.5"
},
"devDependencies": {
"unicode-emoji-json": "^0.9.0",
"vitest": "^5.0.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
// Regenerates src/emojiData.ts from the Unicode emoji list (npm: unicode-emoji-json).
// node scripts/gen-emoji.mjs
// Capped at Emoji 13.1 so every emoji in the picker renders on older Android phones.
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const groups = require('unicode-emoji-json/data-by-group.json');
const MAX_VERSION = 13.1;
const SKIP = new Set(['component']); // skin tone swatches etc.
const out = groups
.filter((g) => !SKIP.has(g.slug))
.map((g) => ({
name: g.name.replace(' & ', ' and '),
// "emoji name" pairs, space-separated words; one string per emoji keeps the file small.
emojis: g.emojis.filter((e) => Number.parseFloat(e.emoji_version) <= MAX_VERSION).map((e) => `${e.emoji} ${e.name}`),
}));
const header = `// Generated by scripts/gen-emoji.mjs from unicode-emoji-json (MIT). Don't edit by hand.\n`;
writeFileSync(
new URL('../src/emojiData.ts', import.meta.url),
`${header}export const EMOJI_GROUPS: { name: string; emojis: string[] }[] = ${JSON.stringify(out)};\n`,
);
const count = out.reduce((n, g) => n + g.emojis.length, 0);
console.log(`wrote ${count} emoji in ${out.length} groups (${readFileSync(new URL('../src/emojiData.ts', import.meta.url)).length} bytes)`);
+5
View File
@@ -3,6 +3,7 @@ import type {
AdminStatsDTO,
ConnectionDTO,
FollowListDTO,
ReactionDTO,
FeedItemDTO,
MeDTO,
ProfileDTO,
@@ -126,6 +127,10 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
suspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'POST', body: {} }),
unsuspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'DELETE' }),
/** React to a play with one emoji (replaces your earlier one). Returns that play's reactions. */
react: (listenId: string, emoji: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
connections: () => request<ConnectionDTO[]>('/connections'),
+12
View File
@@ -35,6 +35,13 @@ export interface MeDTO extends UserSummaryDTO {
entitlements: Entitlements;
}
/** One emoji on a play, and who left it (newest first). */
export interface ReactionDTO {
emoji: string;
count: number;
users: UserSummaryDTO[];
}
export interface NowPlayingDTO {
user: UserSummaryDTO;
track: TrackDTO;
@@ -46,6 +53,9 @@ export interface NowPlayingDTO {
sourceUrl: string | null;
progressMs: number | null;
updatedAt: string;
/** The play this is, for reactions (null for very old rows). */
listenId: string | null;
reactions: ReactionDTO[];
}
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
@@ -71,9 +81,11 @@ export interface FeaturedSongDTO {
}
export interface RecentPlayDTO {
listenId: string;
track: TrackDTO;
source: NowPlayingProvider;
playedAt: string;
reactions: ReactionDTO[];
}
export interface ProfileDTO {
File diff suppressed because one or more lines are too long
+1
View File
@@ -8,3 +8,4 @@ export * from './devices';
export * from './links';
export * from './qr';
export * from './progress';
export * from './reactions';
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, isSingleEmoji, searchEmoji } from './reactions';
describe('emoji', () => {
it('has every group and the quick reactions in it', () => {
expect(EMOJI_PICKER_GROUPS.map((g) => g.name)).toContain('Smileys and Emotion');
const all = new Set(EMOJI_PICKER_GROUPS.flatMap((g) => g.emojis.map((e) => e.emoji)));
for (const q of QUICK_REACTIONS) expect(all.has(q)).toBe(true);
});
it('searches by word starts', () => {
expect(searchEmoji('fire').map((e) => e.emoji)).toContain('🔥');
expect(searchEmoji('red hea').map((e) => e.emoji)).toContain('❤️');
expect(searchEmoji(' ')).toEqual([]);
});
it('accepts exactly one emoji', () => {
for (const ok of ['🔥', '❤️', '👍🏽', '👩‍💻', '🇯🇵', '🏳️‍🌈']) expect(isSingleEmoji(ok)).toBe(true);
for (const bad of ['', 'a', '🔥🔥', 'hi 🔥', '<b>', '1']) expect(isSingleEmoji(bad)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { EMOJI_GROUPS } from './emojiData';
/** Shown first, one tap away. Everything else is in the full picker. */
export const QUICK_REACTIONS = ['❤️', '🔥', '😂', '😮', '😢', '👏'] as const;
export interface EmojiEntry {
emoji: string;
name: string;
}
const split = (entry: string): EmojiEntry => {
const i = entry.indexOf(' ');
return { emoji: entry.slice(0, i), name: entry.slice(i + 1) };
};
/** The full picker's groups (Smileys and Emotion, People and Body, …). */
export const EMOJI_PICKER_GROUPS: { name: string; emojis: EmojiEntry[] }[] = EMOJI_GROUPS.map((g) => ({
name: g.name,
emojis: g.emojis.map(split),
}));
const ALL = EMOJI_PICKER_GROUPS.flatMap((g) => g.emojis);
/** Search by name: every word typed has to match the start of a word in the name. */
export function searchEmoji(query: string, limit = 80): EmojiEntry[] {
const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [];
return ALL.filter((e) => {
const nameWords = e.name.toLowerCase().split(/[\s_:-]+/);
return words.every((w) => nameWords.some((n) => n.startsWith(w)));
}).slice(0, limit);
}
/** One emoji (including flags, skin tones and ZWJ sequences like 👩‍💻), and nothing else. */
export function isSingleEmoji(value: string): boolean {
if (!value || value.length > 32) return false;
const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)];
return graphemes.length === 1 && /\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(value);
}