Skip to content
173 linesCodeBlameRaw
1/**
2 * Reactions and a workspace's own emoji: what may be reacted with, what a
3 * custom emoji may be named, what image it may be, and how reactions are
4 * counted for a page of messages. Pure, so it is tested apart from the
5 * service.
6 */
7
8import { STANDARD_SHORTCODES } from "./standard-emoji.ts";
9
10/** The most different emoji on one message. */
11export const MAX_REACTIONS_PER_MESSAGE = 50;
12/** How many of the people who reacted with one emoji a message carries; the count says how many in all. */
13export const REACTORS_SHOWN = 10;
14
15/** The largest custom emoji file, in bytes. */
16export const MAX_EMOJI_BYTES = 256 * 1024;
17/** The widest and tallest custom emoji, in pixels. */
18export const MAX_EMOJI_SIDE = 512;
19
20/** Who may add a workspace's emoji: every member, or only its owners. */
21export type EmojiUpload = "members" | "admins";
22
23const CUSTOM = /^:([a-z0-9_+-]{2,32}):$/;
24const NAME = /^[a-z0-9_+-]{2,32}$/;
25/** What a lone emoji grapheme starts with: a pictograph, a flag (two regional letters), or a keycap. */
26const PICTOGRAPH = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}{2}|[#*0-9]\uFE0F?\u20E3)/u;
27
28const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
29
30/** Whether `text` is exactly one emoji: one grapheme cluster that is a pictograph, a flag or a keycap. */
31export function isUnicodeEmoji(text: string): boolean {
32 if (!text || text.length > 32) return false;
33 const parts = [...graphemes.segment(text)];
34 return parts.length === 1 && PICTOGRAPH.test(text);
35}
36
37const VS16 = String.fromCodePoint(0xfe0f);
38
39/**
40 * One spelling per emoji: one shown as an emoji by default needs no
41 * variation selector after it, so `👍` and `👍` + U+FE0F are one reaction.
42 */
43export function plainEmoji(emoji: string): string {
44 return emoji.endsWith(VS16) && /^\p{Emoji_Presentation}$/u.test(emoji.slice(0, -1)) ? emoji.slice(0, -1) : emoji;
45}
46
47export type ReactionEmoji = { ok: true; emoji: string; custom: string | null } | { ok: false; message: string };
48
49/**
50 * What a reaction is with: one Unicode emoji, or `:name:` for one of the
51 * workspace's own (`custom` is its name, which the caller checks exists).
52 * A standard shortcode is sent as the emoji itself, not by name.
53 */
54export function reactionEmoji(input: unknown): ReactionEmoji {
55 const text = typeof input === "string" ? input.trim() : "";
56 const custom = CUSTOM.exec(text);
57 if (custom) {
58 if (STANDARD_SHORTCODES.has(custom[1]!)) return { ok: false, message: `Send ${text} as the emoji itself.` };
59 return { ok: true, emoji: text, custom: custom[1]! };
60 }
61 if (isUnicodeEmoji(text)) return { ok: true, emoji: plainEmoji(text), custom: null };
62 return { ok: false, message: "React with one emoji." };
63}
64
65/** A custom emoji's name as kept: lowercase, without colons. */
66export function emojiName(input: unknown): { ok: true; name: string } | { ok: false; message: string } {
67 const name = (typeof input === "string" ? input : "").trim().replace(/^:+|:+$/g, "").toLowerCase();
68 if (name.length < 2 || name.length > 32) return { ok: false, message: "An emoji name is 2 to 32 characters." };
69 if (!NAME.test(name)) return { ok: false, message: "An emoji name can only use lowercase letters, digits, -, _ and +." };
70 if (STANDARD_SHORTCODES.has(name)) return { ok: false, message: `:${name}: is a standard emoji. Pick another name.` };
71 return { ok: true, name };
72}
73
74export type EmojiImage = { content_type: "image/png" | "image/gif" | "image/webp"; width: number; height: number };
75
76const ascii = (bytes: Uint8Array, at: number, length: number) => String.fromCharCode(...bytes.subarray(at, at + length));
77const le16 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8);
78const le24 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16);
79const be32 = (b: Uint8Array, at: number) => ((b[at]! << 24) >>> 0) + (b[at + 1]! << 16) + (b[at + 2]! << 8) + b[at + 3]!;
80
81/** What an image is and how big, from its bytes (never its name), or null for anything else. */
82export function sniffImage(bytes: Uint8Array): EmojiImage | null {
83 if (bytes.length >= 24 && ascii(bytes, 1, 3) === "PNG" && bytes[0] === 0x89 && ascii(bytes, 12, 4) === "IHDR") {
84 return { content_type: "image/png", width: be32(bytes, 16), height: be32(bytes, 20) };
85 }
86 if (bytes.length >= 10 && (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")) {
87 return { content_type: "image/gif", width: le16(bytes, 6), height: le16(bytes, 8) };
88 }
89 if (bytes.length >= 30 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
90 const chunk = ascii(bytes, 12, 4);
91 if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
92 return { content_type: "image/webp", width: le16(bytes, 26) & 0x3fff, height: le16(bytes, 28) & 0x3fff };
93 }
94 if (chunk === "VP8L" && bytes[20] === 0x2f) {
95 const [b0, b1, b2, b3] = [bytes[21]!, bytes[22]!, bytes[23]!, bytes[24]!];
96 return {
97 content_type: "image/webp",
98 width: 1 + (((b1 & 0x3f) << 8) | b0),
99 height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),
100 };
101 }
102 if (chunk === "VP8X") return { content_type: "image/webp", width: 1 + le24(bytes, 24), height: 1 + le24(bytes, 27) };
103 }
104 return null;
105}
106
107/** A custom emoji's file, checked: a PNG, GIF or WebP, small enough. */
108export function emojiImage(bytes: Uint8Array): { ok: true; image: EmojiImage } | { ok: false; message: string } {
109 if (!bytes.length) return { ok: false, message: "Choose an image." };
110 if (bytes.length > MAX_EMOJI_BYTES) return { ok: false, message: "An emoji image is at most 256 KB." };
111 const image = sniffImage(bytes);
112 if (!image) return { ok: false, message: "An emoji image is a PNG, GIF or WebP." };
113 if (!image.width || !image.height) return { ok: false, message: "That image has no size." };
114 if (image.width > MAX_EMOJI_SIDE || image.height > MAX_EMOJI_SIDE) {
115 return { ok: false, message: `An emoji image is at most ${MAX_EMOJI_SIDE}×${MAX_EMOJI_SIDE} pixels.` };
116 }
117 return { ok: true, image };
118}
119
120/** The bytes of a base64 file, or null when it is not base64. */
121export function fromBase64(data: unknown): Uint8Array | null {
122 if (typeof data !== "string" || data.length > Math.ceil((MAX_EMOJI_BYTES * 4) / 3) + 8 + 1024) return null;
123 try {
124 const plain = atob(data.replace(/^data:[^,]*,/, "").replace(/\s+/g, ""));
125 return Uint8Array.from(plain, (c) => c.charCodeAt(0));
126 } catch {
127 return null;
128 }
129}
130
131/** Whether a member may add one more reaction: it is already there, or there is room for another kind. */
132export function roomForReaction(existing: ReadonlySet<string>, emoji: string): boolean {
133 return existing.has(emoji) || existing.size < MAX_REACTIONS_PER_MESSAGE;
134}
135
136/** Whether someone may add a workspace's emoji. */
137export function mayUpload(setting: EmojiUpload, role: "owner" | "member" | null): boolean {
138 if (!role) return false;
139 return setting === "members" || role === "owner";
140}
141
142/** Whether someone may remove a custom emoji: whoever added it, or an owner. */
143export function mayRemove(createdBy: string, viewer: string, role: "owner" | "member" | null): boolean {
144 return role === "owner" || (role != null && createdBy === viewer);
145}
146
147export type ReactionRow = { message_id: string; emoji: string; principal: string; created_at: string };
148
149/** One emoji's reactions on one message, before profiles are resolved. */
150export type ReactionTally = { emoji: string; count: number; me: boolean; by: string[] };
151
152/**
153 * Each message's reactions: one entry per emoji, in the order each was
154 * first used, counting everyone who used it, whether `me` (a member key)
155 * did, and the first `REACTORS_SHOWN` of them.
156 */
157export function tallyReactions(rows: ReactionRow[], me: string | null): Map<string, ReactionTally[]> {
158 const ordered = [...rows].sort((a, b) => a.created_at.localeCompare(b.created_at) || a.principal.localeCompare(b.principal));
159 const out = new Map<string, ReactionTally[]>();
160 for (const row of ordered) {
161 const list = out.get(row.message_id) ?? [];
162 let entry = list.find((r) => r.emoji === row.emoji);
163 if (!entry) {
164 entry = { emoji: row.emoji, count: 0, me: false, by: [] };
165 list.push(entry);
166 }
167 entry.count += 1;
168 if (row.principal === me) entry.me = true;
169 if (entry.by.length < REACTORS_SHOWN) entry.by.push(row.principal);
170 out.set(row.message_id, list);
171 }
172 return out;
173}