Files
buzz/desktop/src/shared/api/customEmoji.ts
Taylor Ho 2ea9385015 fix(reactions): support max-length custom emoji (#3833)
**Category:** fix
**User Impact:** Custom emoji with valid 64-character names can now be
used as reactions without errors.

**Problem:** Buzz accepted 64-character custom emoji names during
registration, but rejected them as reactions after the required
surrounding colons made the payload 66 characters. Validation also
differed between desktop, SDK, relay, and storage boundaries.

<img width="554" height="47" alt="image"
src="https://github.com/user-attachments/assets/4013452f-210e-4dd3-9003-f45ff3b28dc8"
/>

**Solution:** Keep the product limit at 64 ASCII characters for custom
emoji names, enforce it consistently when emoji sets are registered, and
allow only valid matching custom reaction payloads up to 66 characters.
Widen the reaction projection to preserve the wrapped payload while
retaining the existing 64-character limit for ordinary reactions.

<details>
<summary>File changes</summary>

**crates/buzz-sdk/src/builders.rs**
Defines the shared custom emoji boundaries and covers accepted
64-character and rejected 65-character shortcodes.

**crates/buzz-relay/src/handlers/ingest.rs**
Validates emoji-set shortcodes and permits 66-character reactions only
when they are valid colon-wrapped custom emoji with a matching tag.

**crates/buzz-db/src/event.rs**
Adds storage regression coverage for maximum-length custom emoji
reactions.

**crates/buzz-db/src/migration.rs**
Verifies the reaction column migration is applied correctly.

**desktop/src/shared/api/customEmoji.ts**
Enforces the existing 64-character shortcode maximum during desktop
normalization and registration/import.

**desktop/src/shared/api/customEmoji.test.mjs**
Covers the desktop shortcode boundary.

**migrations/0027_long_reaction_payloads.sql**
Widens stored reaction payloads to 66 characters for the two required
surrounding colons.

**schema/schema.sql**
Keeps the desired schema aligned with the migration.

</details>

## Reproduction Steps

1. Register or import a custom emoji whose ASCII shortcode is exactly 64
characters.
2. Select that emoji as a reaction to a message.
3. Confirm the reaction publishes, persists, and renders without an
error.
4. Attempt to register a 65-character shortcode and confirm it is
rejected.
5. Publish an ordinary or malformed reaction over 64 characters and
confirm the relay rejects it.

## Verification

- `cargo test -p buzz-sdk`: 243 passed
- `cargo test -p buzz-db`: 94 passed, 152 Postgres-required tests
ignored
- `pnpm test` in `desktop`: 3,859 passed
- `cargo test -p buzz-relay`: 795 passed, 9 existing
Postgres-unavailable failures, 35 ignored; new reaction boundary tests
pass directly
- `cargo fmt --all -- --check`
- `git diff --check`

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-05 21:02:57 +00:00

226 lines
7.9 KiB
TypeScript

/**
* Community custom emoji (NIP-30, per-user sets).
*
* Each member publishes their OWN kind:30030 parameterized-replaceable event,
* signed as themselves, keyed by `(pubkey, 30030, "buzz:custom-emoji")`. The
* "community palette" shown in the picker/renderer is the client-side UNION of
* every member's set, collapsed to one entry per shortcode (deterministic
* winner) — a view computed on read, not stored state. Downstream identity is
* shortcode-only (emoji-mart id, autocomplete key, reaction lookup, send tag),
* so the palette must never expose two URLs under one shortcode. Adding an
* emoji is a read-my-own-set → mutate → republish
* of my own 30030 (relay ingest allowlists member-authored 30030/10030 as
* UsersWrite, and the generic NIP-33 replace path keeps only the latest per
* `(pubkey, d_tag)`).
*
* Replaces the earlier relay-owned single-set + kind:9037 command model.
*/
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type { RelayEvent } from "@/shared/api/types";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
/** NIP-30 emoji set (parameterized-replaceable). */
export const KIND_EMOJI_SET = 30030;
/** d-tag for a member's own custom emoji set. */
export const CUSTOM_EMOJI_SET_D_TAG = "buzz:custom-emoji";
/**
* Resolve the image URL for a reaction whose content is a custom-emoji
* `:shortcode:`, from the community set. Returns undefined for unicode
* reactions or unknown shortcodes (the kind:7 then carries no emoji tag).
*/
export function reactionEmojiUrl(
emoji: string,
set: ReadonlyArray<CustomEmoji> | undefined,
): string | undefined {
if (!set || !emoji.startsWith(":") || !emoji.endsWith(":")) return undefined;
const shortcode = emoji.slice(1, -1).toLowerCase();
return set.find((e) => e.shortcode === shortcode)?.url;
}
/** NIP-30 shortcode chars. Matches the relay's `[A-Za-z0-9_-]` validation. */
const SHORTCODE_RE = /^[a-z0-9_-]+$/;
const MAX_SHORTCODE_LENGTH = 64;
/**
* Normalize a shortcode the same way the relay does: strip surrounding colons
* and lowercase. Returns null if the result is empty or has invalid chars.
*/
export function normalizeShortcode(raw: string): string | null {
const stripped = raw.trim().replace(/^:+/, "").replace(/:+$/, "");
const lower = stripped.toLowerCase();
return lower.length <= MAX_SHORTCODE_LENGTH && SHORTCODE_RE.test(lower)
? lower
: null;
}
/**
* Suggest a valid custom-emoji shortcode from an uploaded filename.
* Mirrors Slack's file-first flow: strip the extension, lowercase, and collapse
* runs of invalid characters into a single underscore.
*/
export function suggestShortcodeFromFilename(filename: string): string | null {
const basename = filename
.trim()
.replace(/^.*[/\\]/, "")
.replace(/\.[^.]*$/, "");
const suggested = basename
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[_-]+|[_-]+$/g, "");
return normalizeShortcode(suggested);
}
/**
* Parse NIP-30 `["emoji", shortcode, url]` tags from a single event into a
* custom-emoji list. Shortcodes are normalized; malformed/duplicate entries
* within the one event are skipped (first wins).
*/
export function customEmojiFromTags(
tags: ReadonlyArray<ReadonlyArray<string>>,
): CustomEmoji[] {
const seen = new Set<string>();
const emoji: CustomEmoji[] = [];
for (const tag of tags) {
const [name, rawShortcode, url] = tag;
if (name !== "emoji") continue;
if (!rawShortcode || !url) continue;
const shortcode = normalizeShortcode(rawShortcode);
if (!shortcode) continue;
if (seen.has(shortcode)) continue;
seen.add(shortcode);
emoji.push({ shortcode, url });
}
return emoji;
}
export function customEmojiFromEvent(event: RelayEvent | null): CustomEmoji[] {
if (!event) return [];
return customEmojiFromTags(event.tags);
}
/**
* Union every member's kind:30030 set into the community palette, collapsed to
* one entry per shortcode. When members disagree on a shortcode's URL, the
* most recently published set wins (`created_at` is signed event data, so this
* is as deterministic and fetch-order-independent as any pure function of the
* events); equal timestamps tie-break to the lexicographically-smallest URL so
* the same set of events always yields the same palette. Output is sorted by
* shortcode.
*/
export function unionCustomEmoji(
events: ReadonlyArray<RelayEvent>,
): CustomEmoji[] {
const byShortcode = new Map<string, { url: string; createdAt: number }>();
for (const event of events) {
for (const { shortcode, url } of customEmojiFromTags(event.tags)) {
const winner = byShortcode.get(shortcode);
if (
winner === undefined ||
event.created_at > winner.createdAt ||
(event.created_at === winner.createdAt && url < winner.url)
) {
byShortcode.set(shortcode, { url, createdAt: event.created_at });
}
}
}
return [...byShortcode]
.map(([shortcode, { url }]) => ({ shortcode, url }))
.sort((a, b) => a.shortcode.localeCompare(b.shortcode));
}
/** Fetch every member's 30030 set (catch-up). */
export async function fetchCommunityEmojiEvents(): Promise<RelayEvent[]> {
return relayClient.fetchEvents({
kinds: [KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
// One 30030 per member; a community has far fewer than this. The relay
// already keeps only the latest per (pubkey, d_tag), so this is the member
// count, not history depth.
limit: 500,
});
}
/** Fetch the community custom emoji palette (union). Empty when none. */
export async function listCustomEmoji(): Promise<CustomEmoji[]> {
const events = await fetchCommunityEmojiEvents();
return unionCustomEmoji(events);
}
/** Fetch the caller's OWN current set (latest 30030 under the d-tag). */
export async function fetchOwnEmoji(): Promise<CustomEmoji[]> {
const { pubkey: me } = await getIdentity();
if (!me) return [];
const events = await relayClient.fetchEvents({
kinds: [KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
authors: [me],
limit: 1,
});
return customEmojiFromEvent(events[events.length - 1] ?? null);
}
/** Publish the caller's (replaced) own 30030 set, signed as the caller. */
async function publishOwnSet(
emojis: ReadonlyArray<CustomEmoji>,
timeoutMessage: string,
errorMessage: string,
): Promise<void> {
const tags: string[][] = [["d", CUSTOM_EMOJI_SET_D_TAG]];
for (const { shortcode, url } of emojis) {
tags.push(["emoji", shortcode, url]);
}
const event = await signRelayEvent({
kind: KIND_EMOJI_SET,
content: "",
tags,
});
await relayClient.publishEvent(event, timeoutMessage, errorMessage);
}
/**
* Add/update a custom emoji in the caller's OWN set (read-modify-write).
* `url` should be a Blossom blob URL. Returns the normalized shortcode.
*/
export async function setCustomEmoji(
shortcode: string,
url: string,
): Promise<string> {
const normalized = normalizeShortcode(shortcode);
if (!normalized) {
throw new Error(
"Invalid emoji name. Use letters, numbers, hyphen, or underscore.",
);
}
const own = await fetchOwnEmoji();
const next = own.filter((e) => e.shortcode !== normalized);
next.push({ shortcode: normalized, url });
await publishOwnSet(
next,
"Timed out while adding emoji.",
"Failed to add emoji.",
);
return normalized;
}
/** Remove a custom emoji from the caller's OWN set by shortcode. */
export async function removeCustomEmoji(shortcode: string): Promise<void> {
const normalized = normalizeShortcode(shortcode);
if (!normalized) return;
const own = await fetchOwnEmoji();
const next = own.filter((e) => e.shortcode !== normalized);
if (next.length === own.length) return; // not present — nothing to republish
await publishOwnSet(
next,
"Timed out while removing emoji.",
"Failed to remove emoji.",
);
}