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
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { colors, radius, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Reactions } from './Reactions';
|
||||
import { TrackCard } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
@@ -95,6 +96,7 @@ export function NowPlayingCard({
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
</Animated.View>
|
||||
<ProgressBar np={np} />
|
||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -4,13 +4,17 @@ import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingSe
|
||||
import { showError } from '@/lib/errors';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Card, Heading } from './ui';
|
||||
import { Reactions } from './Reactions';
|
||||
|
||||
export function RecentPlays({
|
||||
plays,
|
||||
preferred,
|
||||
attribution = {},
|
||||
ownerId,
|
||||
}: {
|
||||
plays: RecentPlayDTO[];
|
||||
/** Whose plays these are (they can't react to their own). */
|
||||
ownerId: string;
|
||||
preferred: StreamingService | null | undefined;
|
||||
attribution?: ProfileDTO['attribution'];
|
||||
}) {
|
||||
@@ -20,12 +24,12 @@ export function RecentPlays({
|
||||
<Card style={{ gap: 0 }}>
|
||||
<Heading style={{ marginBottom: space.sm }}>Recently played</Heading>
|
||||
{plays.map((p, i) => (
|
||||
<View key={p.listenId ?? `${p.playedAt}-${i}`} style={i > 0 && styles.divider}>
|
||||
<Pressable
|
||||
key={`${p.playedAt}-${i}`}
|
||||
disabled={!preferred}
|
||||
accessibilityRole={preferred ? 'link' : undefined}
|
||||
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 ? (
|
||||
<Image source={p.track.artworkUrl} style={styles.art} />
|
||||
) : (
|
||||
@@ -43,6 +47,8 @@ export function RecentPlays({
|
||||
</View>
|
||||
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
||||
</Pressable>
|
||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
|
||||
</View>
|
||||
))}
|
||||
{credits.map((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 },
|
||||
artist: { color: colors.muted, fontSize: 13 },
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -102,6 +102,32 @@ input:focus, textarea:focus, select:focus {
|
||||
.share-link { font: inherit; cursor: pointer; color: var(--text); }
|
||||
.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); }
|
||||
.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-actions { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -138,7 +138,7 @@ export function ProfileClient() {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } fr
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Reactions } from './Reactions';
|
||||
import { TrackCard } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
@@ -85,6 +86,7 @@ export function NowPlayingCard({
|
||||
>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
<ProgressBar np={np} />
|
||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</article>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { Reactions } from './Reactions';
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
|
||||
export function RecentPlays({
|
||||
plays,
|
||||
preferred,
|
||||
attribution = {},
|
||||
ownerId,
|
||||
}: {
|
||||
plays: RecentPlayDTO[];
|
||||
/** Whose plays these are (they can't react to their own). */
|
||||
ownerId: string;
|
||||
preferred: StreamingService | null | undefined;
|
||||
attribution?: ProfileDTO['attribution'];
|
||||
}) {
|
||||
@@ -30,7 +34,7 @@ export function RecentPlays({
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={`${p.playedAt}-${i}`}>
|
||||
<li key={p.listenId ?? `${p.playedAt}-${i}`}>
|
||||
{target ? (
|
||||
<a
|
||||
className="recent-row"
|
||||
@@ -44,6 +48,7 @@ export function RecentPlays({
|
||||
) : (
|
||||
<div className="recent-row">{row}</div>
|
||||
)}
|
||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
Generated
+8
@@ -19662,6 +19662,13 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unicode-emoji-json": "^0.9.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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