feat: import Chinese-localized Buzz source snapshot
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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,139 @@
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;
}
@@ -0,0 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned).
///
/// On build, fetches every member's set and collapses to one entry per
/// shortcode (see [unionCustomEmoji]). Re-fetches when the relay session
/// reconnects. The palette is the single source of truth consumed by the
/// renderer, picker, autocomplete, and reaction/send tag plumbing.
class CustomEmojiPaletteNotifier extends AsyncNotifier<List<CustomEmoji>> {
@override
Future<List<CustomEmoji>> build() {
ref.watch(relayClientProvider);
ref.watch(relaySessionProvider);
return _fetch();
}
Future<List<CustomEmoji>> _fetch() async {
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return [];
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
const NostrFilter(
kinds: [kindEmojiSet],
tags: {
'#d': [customEmojiSetDTag],
},
// One 30030 per member; the relay keeps only the latest per
// (pubkey, d_tag), so this bounds member count, not history.
limit: 500,
),
);
return unionCustomEmoji(events);
} catch (_) {
return [];
}
}
/// Force a re-fetch of the community palette.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
}
final customEmojiPaletteProvider =
AsyncNotifierProvider<CustomEmojiPaletteNotifier, List<CustomEmoji>>(
CustomEmojiPaletteNotifier.new,
);
/// Synchronous read of the current palette (empty while loading/error).
/// Convenience for widgets that only need the resolved list, not the async
/// state — renderer, autocomplete, reaction resolution.
final customEmojiListProvider = Provider<List<CustomEmoji>>((ref) {
return ref.watch(customEmojiPaletteProvider).value ?? const [];
});
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Default rendered height of an inline custom emoji, in logical pixels.
/// Roughly matches a line of body text so emoji sit on the baseline cleanly.
const double kCustomEmojiInlineSize = 20.0;
/// A single custom-emoji image rendered as a square, network-loaded glyph.
///
/// Reused everywhere a `:shortcode:` resolves to an image: inline in message
/// bodies, reaction pills, status, and the picker. Falls back to the literal
/// `:shortcode:` text if the image fails to load, so a broken URL never leaves
/// a blank gap.
class CustomEmojiImage extends StatelessWidget {
final String shortcode;
final String url;
final double size;
const CustomEmojiImage({
super.key,
required this.shortcode,
required this.url,
this.size = kCustomEmojiInlineSize,
});
@override
Widget build(BuildContext context) {
final fallbackStyle = DefaultTextStyle.of(context).style;
return MediaImage(
url: url,
width: size,
height: size,
decodeWidth: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
);
}
}
/// gpt_markdown inline component that replaces `:shortcode:` with an inline
/// [CustomEmojiImage] for *known* shortcodes only. Unknown `:foo:` is left as
/// plain text. Matched case-insensitively; resolved via the lowercase palette.
///
/// Parallel to the `_MentionMd` / `_ChannelLinkMd` components in
/// message_content.dart — add an instance to a `GptMarkdown.inlineComponents`
/// list (before the default components) to enable custom emoji in any markdown
/// surface.
class CustomEmojiMd extends InlineMd {
final Map<String, String> _urlByShortcode;
final double size;
late final RegExp _exp = _buildPattern(_urlByShortcode.keys);
CustomEmojiMd(List<CustomEmoji> palette, {this.size = kCustomEmojiInlineSize})
: _urlByShortcode = {for (final e in palette) e.shortcode: e.url};
@override
RegExp get exp => _exp;
@override
InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final shortcode = raw.substring(1, raw.length - 1).toLowerCase();
final url = _urlByShortcode[shortcode];
if (url == null) {
return TextSpan(text: text, style: config.style);
}
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CustomEmojiImage(shortcode: shortcode, url: url, size: size),
);
}
/// Build a regex matching `:shortcode:` for any known shortcode, longest
/// first so a longer name isn't shadowed by a shorter prefix. Matches nothing
/// when the palette is empty (a regex that can never match).
static RegExp _buildPattern(Iterable<String> shortcodes) {
final sorted = shortcodes.where((s) => s.trim().isNotEmpty).toSet().toList()
..sort((a, b) => b.length.compareTo(a.length));
if (sorted.isEmpty) {
// Never matches — gpt_markdown skips this component entirely.
return RegExp(r'(?!x)x');
}
final alternatives = sorted.map(RegExp.escape).join('|');
return RegExp(':(?:$alternatives):', caseSensitive: false);
}
}