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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user