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
+10
View File
@@ -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
+7
View File
@@ -92,6 +92,13 @@
"when": 1791305642128, "when": 1791305642128,
"tag": "0012_email_verification", "tag": "0012_email_verification",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1791308012441,
"tag": "0013_reactions",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -17,6 +17,7 @@ import { avatarRoutes } from './routes/avatars';
import { connectionRoutes } from './routes/connections'; import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices'; import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
import { reactionRoutes } from './routes/reactions';
import { realtimeRoutes } from './routes/realtime'; import { realtimeRoutes } from './routes/realtime';
import { safetyRoutes } from './routes/safety'; import { safetyRoutes } from './routes/safety';
import { trackRoutes } from './routes/tracks'; 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(safetyRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
await app.register(trackRoutes); await app.register(trackRoutes);
await app.register(reactionRoutes);
await app.register(connectionRoutes); await app.register(connectionRoutes);
await app.register(deviceRoutes); await app.register(deviceRoutes);
await app.register(realtimeRoutes); await app.register(realtimeRoutes);
+16
View File
@@ -242,6 +242,22 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`), .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. */ /** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable( export const emailVerifications = pgTable(
'email_verifications', '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 { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { toUserSummary } from '../services/nowPlaying'; import { toUserSummary } from '../services/nowPlaying';
import { removeReactionsBetween } from './reactions';
async function findUser(username: string) { async function findUser(username: string) {
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase())); 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); await followsChanged(me.id, them.id);
return { ok: true }; return { ok: true };
}); });
+49 -4
View File
@@ -1,5 +1,5 @@
import { and, desc, eq, inArray, lt } from 'drizzle-orm'; import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared'; import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { publicAvatarUrl } from '../lib/avatars'; import { publicAvatarUrl } from '../lib/avatars';
@@ -15,6 +15,31 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
avatarUrl: publicAvatarUrl(u.avatarUrl), 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". */ /** 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; export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']); 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)) .innerJoin(schema.users, eq(schema.users.id, schema.nowPlaying.userId))
.where(inArray(schema.nowPlaying.userId, userIds)); .where(inArray(schema.nowPlaying.userId, userIds));
const tracks = await getTrackDTOs(rows.map((r) => r.np.trackId)); 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(); const now = Date.now();
return rows return rows
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! })) .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, sourceUrl: np.sourceUrl,
progressMs: positionNow(np, track.durationMs, now), progressMs: positionNow(np, track.durationMs, now),
updatedAt: np.updatedAt.toISOString(), 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)); .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. // 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 [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 plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
const tracks = await getTrackDTOs(plays.map((p) => p.trackId)); const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() })); 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. */ /** Paused by the user, or suspended by a moderator: nothing new is shared either way. */
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() { export async function resetState() {
assertTestTargets(); 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(); await redis.flushdb();
} }
+61
View File
@@ -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
});
});
@@ -18,6 +18,7 @@ import { colors, radius, space } from '@/lib/theme';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer'; import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar'; import { ProgressBar } from './ProgressBar';
import { Reactions } from './Reactions';
import { TrackCard } from './TrackCard'; import { TrackCard } from './TrackCard';
export function NowPlayingCard({ export function NowPlayingCard({
@@ -95,6 +96,7 @@ export function NowPlayingCard({
<TrackCard track={np.track} preferred={preferred} /> <TrackCard track={np.track} preferred={preferred} />
</Animated.View> </Animated.View>
<ProgressBar np={np} /> <ProgressBar np={np} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
</View> </View>
); );
} }
+1 -1
View File
@@ -155,7 +155,7 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
<Body muted>Nothing playing right now.</Body> <Body muted>Nothing playing right now.</Body>
)} )}
<RecentPlays plays={profile.recent} preferred={me?.preferredService} attribution={profile.attribution} /> <RecentPlays plays={profile.recent} preferred={me?.preferredService} attribution={profile.attribution} ownerId={user.id} />
</Screen> </Screen>
); );
} }
+198
View File
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { tapHaptic } from '@/lib/haptics';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
/**
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* opens the quick six, and "+" the full picker. One reaction per person.
*/
export function Reactions({
listenId,
reactions,
ownerId,
style,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
style?: ViewStyle;
}) {
const { me } = useSession();
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
const shown = local && local.from === reactions ? local.value : reactions;
const [quickOpen, setQuickOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
setQuickOpen(false);
setPickerOpen(false);
tapHaptic();
try {
const value = emoji === mine ? await api.unreact(listenId) : await api.react(listenId, emoji);
setLocal({ from: reactions, value });
} catch (err) {
showError(err, "Couldn't react");
}
};
return (
<View style={[styles.wrap, style]}>
<View style={styles.row}>
{shown.map((r) => (
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
<Pressable
disabled={!canReact}
onPress={() => choose(r.emoji)}
accessibilityRole="button"
accessibilityLabel={`${r.emoji} ${r.count}: ${names(r)}`}
style={({ pressed }) => [styles.chip, r.emoji === mine && styles.mine, pressed && { transform: [{ scale: 0.9 }] }]}>
<Text style={styles.emoji}>{r.emoji}</Text>
{r.count > 1 && <Text style={styles.count}>{r.count}</Text>}
</Pressable>
</Animated.View>
))}
{canReact && (
<Pressable
onPress={() => setQuickOpen((v) => !v)}
accessibilityRole="button"
accessibilityLabel="Add a reaction"
hitSlop={6}
style={[styles.chip, quickOpen && styles.mine]}>
<Text style={[styles.emoji, { color: colors.muted }]}>☺</Text>
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
</Pressable>
)}
</View>
{quickOpen && (
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
{QUICK_REACTIONS.map((e) => (
<Pressable key={e} onPress={() => choose(e)} hitSlop={4} style={[styles.quickItem, e === mine && styles.mine]} accessibilityLabel={`React ${e}`}>
<Text style={styles.quickEmoji}>{e}</Text>
</Pressable>
))}
<Pressable onPress={() => (setQuickOpen(false), setPickerOpen(true))} hitSlop={4} style={styles.quickItem} accessibilityLabel="More emoji">
<Text style={[styles.quickEmoji, { color: colors.muted, fontSize: 22 }]}>+</Text>
</Pressable>
</Animated.View>
)}
<EmojiPicker visible={pickerOpen} onPick={choose} onClose={() => setPickerOpen(false)} />
</View>
);
}
function names(r: ReactionDTO) {
const list = r.users.map((u) => u.displayName ?? u.username);
const extra = r.count - list.length;
return extra > 0 ? `${list.join(', ')} and ${extra} more` : list.join(', ');
}
/** Every emoji, by category, with search, in a bottom sheet. */
function EmojiPicker({ visible, onPick, onClose }: { visible: boolean; onPick: (emoji: string) => void; onClose: () => void }) {
const [query, setQuery] = useState('');
const [group, setGroup] = useState(0);
const results = query.trim() ? searchEmoji(query, 120) : EMOJI_PICKER_GROUPS[group]!.emojis;
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel="Close" />
<View style={styles.sheet}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search emoji"
placeholderTextColor={colors.muted}
style={styles.search}
autoCorrect={false}
/>
{!query.trim() && (
<View style={styles.tabs}>
{EMOJI_PICKER_GROUPS.map((g, i) => (
<Pressable key={g.name} onPress={() => setGroup(i)} accessibilityLabel={g.name} style={[styles.tab, i === group && styles.tabOn]}>
<Text style={styles.tabEmoji}>{g.emojis[0]?.emoji}</Text>
</Pressable>
))}
</View>
)}
<FlatList
data={results}
key={query.trim() ? 'search' : `group-${group}`}
numColumns={8}
keyExtractor={(e) => e.emoji}
initialNumToRender={64}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<Pressable onPress={() => onPick(item.emoji)} style={styles.cell} accessibilityLabel={item.name}>
<Text style={styles.cellEmoji}>{item.emoji}</Text>
</Pressable>
)}
ListEmptyComponent={<Text style={{ color: colors.muted, padding: space.lg }}>No emoji match “{query}”.</Text>}
/>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
wrap: { marginTop: space.md, gap: space.sm },
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
paddingHorizontal: 9,
paddingVertical: 3,
borderRadius: radius.pill,
backgroundColor: 'rgba(255,255,255,0.07)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.16)',
},
mine: { backgroundColor: 'rgba(123,47,247,0.3)', borderColor: 'rgba(123,47,247,0.75)' },
emoji: { fontSize: 16 },
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'] },
quick: {
flexDirection: 'row',
alignSelf: 'flex-start',
alignItems: 'center',
gap: 2,
padding: 4,
borderRadius: radius.pill,
backgroundColor: '#1a1535',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.16)',
},
quickItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
quickEmoji: { fontSize: 26 },
backdrop: { flex: 1, backgroundColor: 'rgba(5,3,15,0.6)' },
sheet: {
height: '55%',
backgroundColor: '#15112c',
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
padding: space.md,
gap: space.sm,
},
search: {
color: colors.text,
backgroundColor: 'rgba(255,255,255,0.07)',
borderRadius: radius.md,
paddingHorizontal: space.md,
paddingVertical: 10,
fontSize: 15,
},
tabs: { flexDirection: 'row', justifyContent: 'space-between' },
tab: { padding: 6, borderRadius: radius.sm, opacity: 0.55 },
tabOn: { opacity: 1, backgroundColor: 'rgba(255,255,255,0.1)' },
tabEmoji: { fontSize: 20 },
cell: { flex: 1 / 8, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
cellEmoji: { fontSize: 28 },
});
+29 -22
View File
@@ -4,13 +4,17 @@ import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingSe
import { showError } from '@/lib/errors'; import { showError } from '@/lib/errors';
import { colors, radius, space } from '@/lib/theme'; import { colors, radius, space } from '@/lib/theme';
import { Card, Heading } from './ui'; import { Card, Heading } from './ui';
import { Reactions } from './Reactions';
export function RecentPlays({ export function RecentPlays({
plays, plays,
preferred, preferred,
attribution = {}, attribution = {},
ownerId,
}: { }: {
plays: RecentPlayDTO[]; plays: RecentPlayDTO[];
/** Whose plays these are (they can't react to their own). */
ownerId: string;
preferred: StreamingService | null | undefined; preferred: StreamingService | null | undefined;
attribution?: ProfileDTO['attribution']; attribution?: ProfileDTO['attribution'];
}) { }) {
@@ -20,29 +24,31 @@ export function RecentPlays({
<Card style={{ gap: 0 }}> <Card style={{ gap: 0 }}>
<Heading style={{ marginBottom: space.sm }}>Recently played</Heading> <Heading style={{ marginBottom: space.sm }}>Recently played</Heading>
{plays.map((p, i) => ( {plays.map((p, i) => (
<Pressable <View key={p.listenId ?? `${p.playedAt}-${i}`} style={i > 0 && styles.divider}>
key={`${p.playedAt}-${i}`} <Pressable
disabled={!preferred} disabled={!preferred}
accessibilityRole={preferred ? 'link' : undefined} accessibilityRole={preferred ? 'link' : undefined}
onPress={() => preferred && Linking.openURL(openUrl(p.track, preferred).url).catch((e) => showError(e, "Couldn't open link"))} onPress={() => preferred && Linking.openURL(openUrl(p.track, preferred).url).catch((e) => showError(e, "Couldn't open link"))}
style={({ pressed }) => [styles.row, i > 0 && styles.divider, pressed && { opacity: 0.6 }]}> style={({ pressed }) => [styles.row, pressed && { opacity: 0.6 }]}>
{p.track.artworkUrl ? ( {p.track.artworkUrl ? (
<Image source={p.track.artworkUrl} style={styles.art} /> <Image source={p.track.artworkUrl} style={styles.art} />
) : ( ) : (
<View style={[styles.art, styles.artEmpty]}> <View style={[styles.art, styles.artEmpty]}>
<Text style={{ color: colors.muted }}>♪</Text> <Text style={{ color: colors.muted }}>♪</Text>
</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={1}>
{p.track.title}
</Text>
<Text style={styles.artist} numberOfLines={1}>
{p.track.artist}
</Text>
</View> </View>
)} <Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
<View style={{ flex: 1, minWidth: 0 }}> </Pressable>
<Text style={styles.title} numberOfLines={1}> <Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
{p.track.title} </View>
</Text>
<Text style={styles.artist} numberOfLines={1}>
{p.track.artist}
</Text>
</View>
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
</Pressable>
))} ))}
{credits.map((s) => ( {credits.map((s) => (
<Text key={s} style={styles.credit} onPress={() => Linking.openURL(attribution[s]!)}> <Text key={s} style={styles.credit} onPress={() => Linking.openURL(attribution[s]!)}>
@@ -61,5 +67,6 @@ const styles = StyleSheet.create({
title: { color: colors.text, fontWeight: '600', fontSize: 14 }, title: { color: colors.text, fontWeight: '600', fontSize: 14 },
artist: { color: colors.muted, fontSize: 13 }, artist: { color: colors.muted, fontSize: 13 },
time: { color: colors.muted, fontSize: 12 }, time: { color: colors.muted, fontSize: 12 },
reactions: { marginLeft: 40 + space.md, marginTop: -space.sm, marginBottom: space.xs },
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm }, credit: { color: colors.muted, fontSize: 12, marginTop: space.sm },
}); });
+26
View File
@@ -102,6 +102,32 @@ input:focus, textarea:focus, select:focus {
.share-link { font: inherit; cursor: pointer; color: var(--text); } .share-link { font: inherit; cursor: pointer; color: var(--text); }
.song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; } .song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; }
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); } .song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.07); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.13); }
.reaction:active:not(:disabled) { transform: scale(0.9); }
.reaction:disabled { cursor: default; }
.reaction.mine { border-color: rgba(123, 47, 247, 0.7); background: rgba(123, 47, 247, 0.28); }
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
.reaction-add { color: var(--muted); font-size: 1rem; }
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
.reaction-quick button:hover { transform: scale(1.25); }
.reaction-quick button.mine { background: rgba(123, 47, 247, 0.35); }
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
.emoji-tabs { display: flex; gap: 0.15rem; margin-bottom: 0.5rem; overflow-x: auto; }
.emoji-tabs button { all: unset; cursor: pointer; font-size: 1.25rem; padding: 0.25rem 0.4rem; border-radius: 0.5rem; opacity: 0.6; }
.emoji-tabs button[aria-selected='true'] { opacity: 1; background: rgba(255, 255, 255, 0.1); }
.emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr)); gap: 0.1rem; max-height: 18rem; overflow-y: auto; }
.emoji-grid button { all: unset; cursor: pointer; font-size: 1.55rem; text-align: center; padding: 0.2rem; border-radius: 0.5rem; }
.emoji-grid button:hover { background: rgba(255, 255, 255, 0.1); }
@media (prefers-reduced-motion: reduce) { .reaction-quick { animation: none; } }
.avatar-picker { display: flex; gap: 1rem; align-items: center; } .avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; } .avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; } .grow { flex: 1; min-width: 0; }
@@ -138,7 +138,7 @@ export function ProfileClient() {
<p className="muted">Nothing playing right now.</p> <p className="muted">Nothing playing right now.</p>
)} )}
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} /> <RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} ownerId={user.id} />
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } fr
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer'; import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar'; import { ProgressBar } from './ProgressBar';
import { Reactions } from './Reactions';
import { TrackCard } from './TrackCard'; import { TrackCard } from './TrackCard';
export function NowPlayingCard({ export function NowPlayingCard({
@@ -85,6 +86,7 @@ export function NowPlayingCard({
> >
<TrackCard track={np.track} preferred={preferred} /> <TrackCard track={np.track} preferred={preferred} />
<ProgressBar np={np} /> <ProgressBar np={np} />
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</article> </article>
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { client, useMe } from '@/lib/api';
import { Modal } from './ProfileMenu';
/**
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* offers the quick six, and "More" opens the full picker. One reaction per person.
*/
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
const { me } = useMe();
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
const shown = local && local.from === reactions ? local.value : reactions;
const [quickOpen, setQuickOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!quickOpen) return;
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setQuickOpen(false);
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [quickOpen]);
const canReact = Boolean(me && listenId && me.id !== ownerId);
if (!listenId || (!canReact && shown.length === 0)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
setQuickOpen(false);
setPickerOpen(false);
setError(null);
try {
const value = emoji === mine ? await client.unreact(listenId) : await client.react(listenId, emoji);
setLocal({ from: reactions, value });
} catch (err) {
setError((err as Error).message);
}
};
return (
<div className="reactions" ref={ref}>
{shown.map((r) => (
<button
key={r.emoji}
type="button"
className={`reaction ${r.emoji === mine ? 'mine' : ''}`}
disabled={!canReact}
onClick={() => choose(r.emoji)}
title={names(r)}
aria-label={`${r.emoji} ${r.count}: ${names(r)}`}
>
<span className="reaction-emoji">{r.emoji}</span>
{r.count > 1 && <span className="reaction-count">{r.count}</span>}
</button>
))}
{canReact && (
<span className="reaction-add-wrap">
<button type="button" className="reaction reaction-add" onClick={() => setQuickOpen((v) => !v)} aria-label="Add a reaction" aria-expanded={quickOpen}>
☺<span aria-hidden>+</span>
</button>
{quickOpen && (
<div className="reaction-quick" role="menu">
{QUICK_REACTIONS.map((e) => (
<button key={e} type="button" role="menuitem" className={e === mine ? 'mine' : ''} onClick={() => choose(e)}>
{e}
</button>
))}
<button type="button" role="menuitem" className="reaction-more" onClick={() => (setQuickOpen(false), setPickerOpen(true))}>
More…
</button>
</div>
)}
</span>
)}
{error && <span className="error">{error}</span>}
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
</div>
);
}
function names(r: ReactionDTO) {
const list = r.users.map((u) => u.displayName ?? u.username);
const extra = r.count - list.length;
return extra > 0 ? `${list.join(', ')} and ${extra} more` : list.join(', ');
}
/** Every emoji, by category, with search. */
export function EmojiPicker({ onPick, onClose }: { onPick: (emoji: string) => void; onClose: () => void }) {
const [query, setQuery] = useState('');
const [group, setGroup] = useState(0);
const results = query.trim() ? searchEmoji(query) : EMOJI_PICKER_GROUPS[group]!.emojis;
return (
<Modal title="React" onClose={onClose}>
<input className="emoji-search" placeholder="Search emoji" value={query} onChange={(e) => setQuery(e.target.value)} autoFocus />
{!query.trim() && (
<div className="emoji-tabs" role="tablist">
{EMOJI_PICKER_GROUPS.map((g, i) => (
<button key={g.name} type="button" role="tab" aria-selected={i === group} title={g.name} onClick={() => setGroup(i)}>
{g.emojis[0]?.emoji}
</button>
))}
</div>
)}
<div className="emoji-grid">
{results.map((e) => (
<button key={e.emoji} type="button" title={e.name} aria-label={e.name} onClick={() => onPick(e.emoji)}>
{e.emoji}
</button>
))}
{results.length === 0 && <p className="muted">No emoji match “{query}”.</p>}
</div>
</Modal>
);
}
+6 -1
View File
@@ -1,13 +1,17 @@
'use client'; 'use client';
import { Reactions } from './Reactions';
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared'; import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
export function RecentPlays({ export function RecentPlays({
plays, plays,
preferred, preferred,
attribution = {}, attribution = {},
ownerId,
}: { }: {
plays: RecentPlayDTO[]; plays: RecentPlayDTO[];
/** Whose plays these are (they can't react to their own). */
ownerId: string;
preferred: StreamingService | null | undefined; preferred: StreamingService | null | undefined;
attribution?: ProfileDTO['attribution']; attribution?: ProfileDTO['attribution'];
}) { }) {
@@ -30,7 +34,7 @@ export function RecentPlays({
</> </>
); );
return ( return (
<li key={`${p.playedAt}-${i}`}> <li key={p.listenId ?? `${p.playedAt}-${i}`}>
{target ? ( {target ? (
<a <a
className="recent-row" className="recent-row"
@@ -44,6 +48,7 @@ export function RecentPlays({
) : ( ) : (
<div className="recent-row">{row}</div> <div className="recent-row">{row}</div>
)} )}
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
</li> </li>
); );
})} })}
+8
View File
@@ -19662,6 +19662,13 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unicode-emoji-json": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.9.0.tgz",
"integrity": "sha512-HdrQW/7SdbXUsTd8uTATv7LvQJkCSgbAgRCDCtOhQ6xn3EAx+xZHQtRniOXWJK3ywIqPegkNhP4U4EbuFsiQng==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-match-property-ecmascript": { "node_modules/unicode-match-property-ecmascript": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
@@ -20300,6 +20307,7 @@
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"unicode-emoji-json": "^0.9.0",
"vitest": "^5.0.3" "vitest": "^5.0.3"
} }
}, },
+1
View File
@@ -16,6 +16,7 @@
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"unicode-emoji-json": "^0.9.0",
"vitest": "^5.0.3" "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, AdminStatsDTO,
ConnectionDTO, ConnectionDTO,
FollowListDTO, FollowListDTO,
ReactionDTO,
FeedItemDTO, FeedItemDTO,
MeDTO, MeDTO,
ProfileDTO, ProfileDTO,
@@ -126,6 +127,10 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
suspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'POST', body: {} }), suspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'POST', body: {} }),
unsuspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'DELETE' }), 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)}`), searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
connections: () => request<ConnectionDTO[]>('/connections'), connections: () => request<ConnectionDTO[]>('/connections'),
+12
View File
@@ -35,6 +35,13 @@ export interface MeDTO extends UserSummaryDTO {
entitlements: Entitlements; 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 { export interface NowPlayingDTO {
user: UserSummaryDTO; user: UserSummaryDTO;
track: TrackDTO; track: TrackDTO;
@@ -46,6 +53,9 @@ export interface NowPlayingDTO {
sourceUrl: string | null; sourceUrl: string | null;
progressMs: number | null; progressMs: number | null;
updatedAt: string; 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. */ /** A feed entry. pinnedAt is set when the viewer pinned this friend. */
@@ -71,9 +81,11 @@ export interface FeaturedSongDTO {
} }
export interface RecentPlayDTO { export interface RecentPlayDTO {
listenId: string;
track: TrackDTO; track: TrackDTO;
source: NowPlayingProvider; source: NowPlayingProvider;
playedAt: string; playedAt: string;
reactions: ReactionDTO[];
} }
export interface ProfileDTO { 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 './links';
export * from './qr'; export * from './qr';
export * from './progress'; 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);
}