Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

176 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 : "")
68 .trim()
69 .replace(/^:+|:+$/g, "")
70 .toLowerCase();
71 if (name.length < 2 || name.length > 32) return { ok: false, message: "An emoji name is 2 to 32 characters." };
72 if (!NAME.test(name)) return { ok: false, message: "An emoji name can only use lowercase letters, digits, -, _ and +." };
73 if (STANDARD_SHORTCODES.has(name)) return { ok: false, message: `:${name}: is a standard emoji. Pick another name.` };
74 return { ok: true, name };
75}
76
77export type EmojiImage = { content_type: "image/png" | "image/gif" | "image/webp"; width: number; height: number };
78
79const ascii = (bytes: Uint8Array, at: number, length: number) => String.fromCharCode(...bytes.subarray(at, at + length));
80const le16 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8);
81const le24 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16);
82const be32 = (b: Uint8Array, at: number) => ((b[at]! << 24) >>> 0) + (b[at + 1]! << 16) + (b[at + 2]! << 8) + b[at + 3]!;
83
84/** What an image is and how big, from its bytes (never its name), or null for anything else. */
85export function sniffImage(bytes: Uint8Array): EmojiImage | null {
86 if (bytes.length >= 24 && ascii(bytes, 1, 3) === "PNG" && bytes[0] === 0x89 && ascii(bytes, 12, 4) === "IHDR") {
87 return { content_type: "image/png", width: be32(bytes, 16), height: be32(bytes, 20) };
88 }
89 if (bytes.length >= 10 && (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")) {
90 return { content_type: "image/gif", width: le16(bytes, 6), height: le16(bytes, 8) };
91 }
92 if (bytes.length >= 30 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
93 const chunk = ascii(bytes, 12, 4);
94 if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
95 return { content_type: "image/webp", width: le16(bytes, 26) & 0x3fff, height: le16(bytes, 28) & 0x3fff };
96 }
97 if (chunk === "VP8L" && bytes[20] === 0x2f) {
98 const [b0, b1, b2, b3] = [bytes[21]!, bytes[22]!, bytes[23]!, bytes[24]!];
99 return {
100 content_type: "image/webp",
101 width: 1 + (((b1 & 0x3f) << 8) | b0),
102 height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)),
103 };
104 }
105 if (chunk === "VP8X") return { content_type: "image/webp", width: 1 + le24(bytes, 24), height: 1 + le24(bytes, 27) };
106 }
107 return null;
108}
109
110/** A custom emoji's file, checked: a PNG, GIF or WebP, small enough. */
111export function emojiImage(bytes: Uint8Array): { ok: true; image: EmojiImage } | { ok: false; message: string } {
112 if (!bytes.length) return { ok: false, message: "Choose an image." };
113 if (bytes.length > MAX_EMOJI_BYTES) return { ok: false, message: "An emoji image is at most 256 KB." };
114 const image = sniffImage(bytes);
115 if (!image) return { ok: false, message: "An emoji image is a PNG, GIF or WebP." };
116 if (!image.width || !image.height) return { ok: false, message: "That image has no size." };
117 if (image.width > MAX_EMOJI_SIDE || image.height > MAX_EMOJI_SIDE) {
118 return { ok: false, message: `An emoji image is at most ${MAX_EMOJI_SIDE}×${MAX_EMOJI_SIDE} pixels.` };
119 }
120 return { ok: true, image };
121}
122
123/** The bytes of a base64 file, or null when it is not base64. */
124export function fromBase64(data: unknown): Uint8Array | null {
125 if (typeof data !== "string" || data.length > Math.ceil((MAX_EMOJI_BYTES * 4) / 3) + 8 + 1024) return null;
126 try {
127 const plain = atob(data.replace(/^data:[^,]*,/, "").replace(/\s+/g, ""));
128 return Uint8Array.from(plain, (c) => c.charCodeAt(0));
129 } catch {
130 return null;
131 }
132}
133
134/** Whether a member may add one more reaction: it is already there, or there is room for another kind. */
135export function roomForReaction(existing: ReadonlySet<string>, emoji: string): boolean {
136 return existing.has(emoji) || existing.size < MAX_REACTIONS_PER_MESSAGE;
137}
138
139/** Whether someone may add a workspace's emoji. */
140export function mayUpload(setting: EmojiUpload, role: "owner" | "member" | null): boolean {
141 if (!role) return false;
142 return setting === "members" || role === "owner";
143}
144
145/** Whether someone may remove a custom emoji: whoever added it, or an owner. */
146export function mayRemove(createdBy: string, viewer: string, role: "owner" | "member" | null): boolean {
147 return role === "owner" || (role != null && createdBy === viewer);
148}
149
150export type ReactionRow = { message_id: string; emoji: string; principal: string; created_at: string };
151
152/** One emoji's reactions on one message, before profiles are resolved. */
153export type ReactionTally = { emoji: string; count: number; me: boolean; by: string[] };
154
155/**
156 * Each message's reactions: one entry per emoji, in the order each was
157 * first used, counting everyone who used it, whether `me` (a member key)
158 * did, and the first `REACTORS_SHOWN` of them.
159 */
160export function tallyReactions(rows: ReactionRow[], me: string | null): Map<string, ReactionTally[]> {
161 const ordered = [...rows].sort((a, b) => a.created_at.localeCompare(b.created_at) || a.principal.localeCompare(b.principal));
162 const out = new Map<string, ReactionTally[]>();
163 for (const row of ordered) {
164 const list = out.get(row.message_id) ?? [];
165 let entry = list.find((r) => r.emoji === row.emoji);
166 if (!entry) {
167 entry = { emoji: row.emoji, count: 0, me: false, by: [] };
168 list.push(entry);
169 }
170 entry.count += 1;
171 if (row.principal === me) entry.me = true;
172 if (entry.by.length < REACTORS_SHOWN) entry.by.push(row.principal);
173 out.set(row.message_id, list);
174 }
175 return out;
176}