Files
buzz/mobile/lib/shared/custom_emoji/custom_emoji.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

140 lines
4.9 KiB
Dart

import 'package:flutter/foundation.dart';
import '../relay/nostr_models.dart';
/// A community custom emoji: a `:shortcode:` mapped to an image URL.
///
/// Mirrors desktop's NIP-30 model (kind:30030, per-user sets unioned into one
/// community palette). Identity downstream is shortcode-only — the palette must
/// never carry two URLs under one shortcode.
@immutable
class CustomEmoji {
final String shortcode;
final String url;
const CustomEmoji({required this.shortcode, required this.url});
@override
bool operator ==(Object other) =>
other is CustomEmoji && other.shortcode == shortcode && other.url == url;
@override
int get hashCode => Object.hash(shortcode, url);
@override
String toString() => 'CustomEmoji($shortcode -> $url)';
}
/// NIP-30 emoji set kind (parameterized-replaceable).
const int kindEmojiSet = 30030;
/// d-tag for a member's own custom emoji set.
const String customEmojiSetDTag = 'buzz:custom-emoji';
final RegExp _shortcodeRe = RegExp(r'^[a-z0-9_-]+$');
/// Normalize a shortcode the way the relay does: strip surrounding colons and
/// lowercase. Returns null if the result is empty or has invalid chars.
String? normalizeShortcode(String raw) {
final stripped = raw
.trim()
.replaceFirst(RegExp(r'^:+'), '')
.replaceFirst(RegExp(r':+$'), '');
final lower = stripped.toLowerCase();
return _shortcodeRe.hasMatch(lower) ? lower : null;
}
/// Parse NIP-30 `["emoji", shortcode, url]` tags from one event's tags into a
/// custom-emoji list. Shortcodes are normalized; malformed/duplicate entries
/// within the one event are skipped (first wins).
List<CustomEmoji> customEmojiFromTags(List<List<String>> tags) {
final seen = <String>{};
final emoji = <CustomEmoji>[];
for (final tag in tags) {
if (tag.isEmpty || tag[0] != 'emoji') continue;
if (tag.length < 3) continue;
final rawShortcode = tag[1];
final url = tag[2];
if (rawShortcode.isEmpty || url.isEmpty) continue;
final shortcode = normalizeShortcode(rawShortcode);
if (shortcode == null) continue;
if (seen.contains(shortcode)) continue;
seen.add(shortcode);
emoji.add(CustomEmoji(shortcode: shortcode, url: url));
}
return emoji;
}
/// 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 the
/// result stays deterministic and fetch-order-independent); equal timestamps
/// tie-break to the lexicographically-smallest URL. Output is sorted by
/// shortcode.
List<CustomEmoji> unionCustomEmoji(Iterable<NostrEvent> events) {
final byShortcode = <String, ({String url, int createdAt})>{};
for (final event in events) {
for (final e in customEmojiFromTags(event.tags)) {
final winner = byShortcode[e.shortcode];
if (winner == null ||
event.createdAt > winner.createdAt ||
(event.createdAt == winner.createdAt &&
e.url.compareTo(winner.url) < 0)) {
byShortcode[e.shortcode] = (url: e.url, createdAt: event.createdAt);
}
}
}
final result =
byShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value.url))
.toList()
..sort((a, b) => a.shortcode.compareTo(b.shortcode));
return result;
}
final RegExp _shortcodeScan = RegExp(r':([a-z0-9_-]+):', caseSensitive: false);
/// Return one `["emoji", shortcode, url]` tag per *distinct* known custom emoji
/// referenced in [content]. Matched case-insensitively; the canonical lowercase
/// shortcode is emitted. Unknown `:foo:` are ignored. Order follows first
/// appearance; each shortcode emitted at most once.
///
/// Mirrors how @mentions become `p` tags: derived from the final content at
/// send time so the event is self-contained for any NIP-30 client.
List<List<String>> buildCustomEmojiTags(
String content,
List<CustomEmoji> customEmoji,
) {
if (customEmoji.isEmpty) return [];
final urlByShortcode = {for (final e in customEmoji) e.shortcode: e.url};
final emitted = <String>{};
final tags = <List<String>>[];
for (final match in _shortcodeScan.allMatches(content)) {
final shortcode = match.group(1)!.toLowerCase();
if (emitted.contains(shortcode)) continue;
final url = urlByShortcode[shortcode];
if (url == null) continue;
emitted.add(shortcode);
tags.add(['emoji', shortcode, url]);
}
return tags;
}
/// Resolve the image URL for a reaction whose content is a custom-emoji
/// `:shortcode:`, from the community palette. Returns null for unicode
/// reactions or unknown shortcodes.
String? reactionEmojiUrl(String emoji, List<CustomEmoji>? palette) {
if (palette == null) return null;
if (!emoji.startsWith(':') || !emoji.endsWith(':')) return null;
final shortcode = emoji.substring(1, emoji.length - 1).toLowerCase();
for (final e in palette) {
if (e.shortcode == shortcode) return e.url;
}
return null;
}