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,190 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
/// Frequently-used emoji, ranked and persisted.
///
/// Mirrors desktop's `useQuickReactionEmojis` (`desktop/src/features/messages/
/// ui/useQuickReactionEmojis.ts`): rank by use count, break ties by recency,
/// cap the stored set. Same storage key shape, so the two clients describe the
/// same concept even though the stores are per-device.
///
/// Persistence is namespaced by community (relay) and account pubkey, matching
/// `ComposeDraftsNotifier` — reaction history is user behaviour and must not
/// leak across an in-place community or account switch.
const _recentEmojiPrefsKey = 'buzz.quick-reaction-emojis.v1';
/// Desktop's `DEFAULT_QUICK_REACTIONS`, plus one mobile-only slot that fits in
/// the compact action sheet, used until the user has reacted enough to fill it.
const defaultQuickEmojis = <String>[
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F602}',
'\u{1F389}',
'\u{1F525}',
];
/// Desktop's `MAX_STORED_REACTIONS`.
const _maxStoredEmoji = 24;
/// One persisted frequently-used emoji ranking entry.
///
/// [emoji] is the Unicode glyph or custom shortcode, [count] is how many times
/// it has been selected, and [lastUsedAt] is the most recent selection time in
/// milliseconds since the Unix epoch.
class RecentEmojiEntry {
/// The Unicode glyph or `:shortcode:` value that was selected.
final String emoji;
/// Number of recorded selections for [emoji].
final int count;
/// Most recent selection time in milliseconds since the Unix epoch.
final int lastUsedAt;
/// Creates a persisted ranking entry for [emoji].
const RecentEmojiEntry({
required this.emoji,
required this.count,
required this.lastUsedAt,
});
Map<String, dynamic> toJson() => {
'emoji': emoji,
'count': count,
'lastUsedAt': lastUsedAt,
};
/// Returns null for anything malformed, so one bad record can't poison the
/// whole list.
static RecentEmojiEntry? fromJson(dynamic raw) {
if (raw is! Map) return null;
final emoji = raw['emoji'];
if (emoji is! String || emoji.trim().isEmpty) return null;
final count = raw['count'];
final lastUsedAt = raw['lastUsedAt'];
return RecentEmojiEntry(
emoji: emoji,
count: count is int && count > 0 ? count : 1,
lastUsedAt: lastUsedAt is int && lastUsedAt > 0 ? lastUsedAt : 0,
);
}
}
/// Sort by count desc, then most-recent-first — desktop's `sortEntries`.
List<RecentEmojiEntry> sortRecentEmoji(List<RecentEmojiEntry> entries) {
final sorted = [...entries];
sorted.sort((left, right) {
final byCount = right.count - left.count;
if (byCount != 0) return byCount;
return right.lastUsedAt - left.lastUsedAt;
});
return sorted;
}
/// Record [emoji] against [entries], returning the new capped, sorted list.
/// Pure so it can be tested without SharedPreferences.
List<RecentEmojiEntry> recordRecentEmoji(
List<RecentEmojiEntry> entries,
String emoji, {
required int now,
}) {
if (emoji.trim().isEmpty) return entries;
final next = <RecentEmojiEntry>[];
var matched = false;
for (final entry in entries) {
if (entry.emoji == emoji) {
matched = true;
next.add(
RecentEmojiEntry(emoji: emoji, count: entry.count + 1, lastUsedAt: now),
);
} else {
next.add(entry);
}
}
if (!matched) {
next.add(RecentEmojiEntry(emoji: emoji, count: 1, lastUsedAt: now));
}
final sorted = sortRecentEmoji(next);
return sorted.length <= _maxStoredEmoji
? sorted
: sorted.sublist(0, _maxStoredEmoji);
}
class RecentEmojiNotifier extends Notifier<List<RecentEmojiEntry>> {
late String _prefsKey;
@override
List<RecentEmojiEntry> build() {
final config = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
_prefsKey = '$_recentEmojiPrefsKey:${config.baseUrl}:$pubkey';
final raw = ref.read(savedPrefsProvider).getString(_prefsKey);
if (raw == null || raw.isEmpty) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return sortRecentEmoji(
decoded.map(RecentEmojiEntry.fromJson).nonNulls.toList(),
);
} catch (_) {
return const [];
}
}
/// Record one use of [emoji]. Called from every place a user picks an emoji:
/// the picker, the quick-reaction row, and toggling an existing pill on.
void record(String emoji) {
final next = recordRecentEmoji(
state,
emoji,
now: DateTime.now().millisecondsSinceEpoch,
);
if (identical(next, state)) return;
state = next;
ref
.read(savedPrefsProvider)
.setString(
_prefsKey,
jsonEncode(next.map((entry) => entry.toJson()).toList()),
);
}
}
final recentEmojiProvider =
NotifierProvider<RecentEmojiNotifier, List<RecentEmojiEntry>>(
RecentEmojiNotifier.new,
);
/// The top [limit] emoji for a quick-reaction row, topped up with
/// [defaultQuickEmojis] so the row is never short. Custom-emoji shortcodes are
/// dropped when they are no longer in the community palette — desktop's
/// `canRenderQuickReactionEmoji` guard, which stops a removed custom emoji from
/// rendering as literal `:shortcode:` text.
List<String> quickReactionEmoji(
List<RecentEmojiEntry> entries, {
required Set<String> customShortcodes,
int limit = 5,
}) {
final result = <String>[];
void add(String emoji) {
if (result.length >= limit || result.contains(emoji)) return;
if (emoji.startsWith(':') && emoji.endsWith(':')) {
final shortcode = emoji.substring(1, emoji.length - 1).toLowerCase();
if (!customShortcodes.contains(shortcode)) return;
}
result.add(emoji);
}
for (final entry in entries) {
add(entry.emoji);
}
for (final emoji in defaultQuickEmojis) {
add(emoji);
}
return result;
}