| 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 | |
| 8 | import { STANDARD_SHORTCODES } from "./standard-emoji.ts"; |
| 9 | |
| 10 | /** The most different emoji on one message. */ |
| 11 | export 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. */ |
| 13 | export const REACTORS_SHOWN = 10; |
| 14 | |
| 15 | /** The largest custom emoji file, in bytes. */ |
| 16 | export const MAX_EMOJI_BYTES = 256 * 1024; |
| 17 | /** The widest and tallest custom emoji, in pixels. */ |
| 18 | export const MAX_EMOJI_SIDE = 512; |
| 19 | |
| 20 | /** Who may add a workspace's emoji: every member, or only its owners. */ |
| 21 | export type EmojiUpload = "members" | "admins"; |
| 22 | |
| 23 | const CUSTOM = /^:([a-z0-9_+-]{2,32}):$/; |
| 24 | const NAME = /^[a-z0-9_+-]{2,32}$/; |
| 25 | /** What a lone emoji grapheme starts with: a pictograph, a flag (two regional letters), or a keycap. */ |
| 26 | const PICTOGRAPH = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}{2}|[#*0-9]\uFE0F?\u20E3)/u; |
| 27 | |
| 28 | const 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. */ |
| 31 | export 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 | |
| 37 | const 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 | */ |
| 43 | export 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 | |
| 47 | export 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 | */ |
| 54 | export 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. */ |
| 66 | export 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 | |
| 74 | export type EmojiImage = { content_type: "image/png" | "image/gif" | "image/webp"; width: number; height: number }; |
| 75 | |
| 76 | const ascii = (bytes: Uint8Array, at: number, length: number) => String.fromCharCode(...bytes.subarray(at, at + length)); |
| 77 | const le16 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8); |
| 78 | const le24 = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16); |
| 79 | const 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. */ |
| 82 | export 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. */ |
| 108 | export 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. */ |
| 121 | export 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. */ |
| 132 | export 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. */ |
| 137 | export 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. */ |
| 143 | export function mayRemove(createdBy: string, viewer: string, role: "owner" | "member" | null): boolean { |
| 144 | return role === "owner" || (role != null && createdBy === viewer); |
| 145 | } |
| 146 | |
| 147 | export 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. */ |
| 150 | export 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 | */ |
| 157 | export 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 | } |