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:
@@ -16,6 +16,7 @@
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unicode-emoji-json": "^0.9.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -8,3 +8,4 @@ export * from './devices';
|
||||
export * from './links';
|
||||
export * from './qr';
|
||||
export * from './progress';
|
||||
export * from './reactions';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user