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
+398
View File
@@ -0,0 +1,398 @@
/// Emoji particle bursts, ported from desktop's `EmojiBurstProvider`
/// (`desktop/src/shared/ui/EmojiBurstProvider.tsx`).
///
/// Desktop paints to a fixed full-window `<canvas>` above everything and steps
/// the particles on `requestAnimationFrame`. Mobile does the same shape:
/// [EmojiBurstOverlay] installs one full-screen [CustomPaint] above the
/// navigator (so bursts survive route pushes and modal sheets), and a [Ticker]
/// steps the same physics.
///
/// The constants below are desktop's, unchanged — the motion is the product
/// decision, and drifting it would make the two clients feel different.
library;
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'positive_emoji.dart';
/// Particles per burst — desktop's `PICKER_PARTICLES_PER_BURST`.
const _particlesPerBurst = 5;
/// Frames a particle lives — desktop's `PICKER_PARTICLE_LIFE_FRAMES`.
const _particleLifeFrames = 108;
/// Desktop's `MAX_ACTIVE`, scaled down: a phone screen holds far fewer
/// particles before it reads as noise, and every one costs a `saveLayer`.
const _maxActiveParticles = 160;
/// Physics runs at a fixed 60Hz step regardless of display refresh rate.
/// Desktop's constants are per-frame deltas on a 60Hz assumption; stepping them
/// once per real frame would run the burst at double speed on a 120Hz panel.
const _stepSeconds = 1 / 60;
/// Cap on catch-up steps per frame so a stalled frame can't spiral.
const _maxStepsPerFrame = 4;
/// Glyph size the cached [TextPainter] is laid out at. Particles scale off this
/// rather than re-laying out text every frame.
const _glyphCachePx = 64.0;
/// The last emoji added as a reaction that is still waiting for its pill to
/// appear on screen.
///
/// Mobile reactions are not optimistic — `addReaction` awaits the relay and the
/// pill only exists once the event echoes back — so a burst fired at pick time
/// would play against a row that has not changed yet. Desktop solves this the
/// same way (`burstEmojiOnRender` in `MessageReactions.tsx`): remember the
/// pending emoji, and let the pill fire the burst from its own position the
/// frame it first renders as reacted-by-me.
@immutable
class PendingReactionBurst {
final String messageId;
final String emoji;
const PendingReactionBurst({required this.messageId, required this.emoji});
@override
bool operator ==(Object other) =>
other is PendingReactionBurst &&
other.messageId == messageId &&
other.emoji == emoji;
@override
int get hashCode => Object.hash(messageId, emoji);
}
/// One slot, not a queue: a burst is a flourish on the reaction you just added,
/// and two adds in flight at once means the newer one is the interesting one.
class PendingReactionBurstNotifier extends Notifier<PendingReactionBurst?> {
@override
PendingReactionBurst? build() => null;
/// Arm a burst for [emoji] on [messageId]. No-op for emoji that don't
/// celebrate, so callers don't have to gate themselves.
void arm(String messageId, String emoji) {
if (!isPositiveEmojiParticle(emoji)) return;
state = PendingReactionBurst(messageId: messageId, emoji: emoji);
}
/// Claim the pending burst if it is [target], returning whether the caller
/// won. The same message can be on screen twice (a thread pushed over its
/// channel), so exactly one pill must fire.
bool claim(PendingReactionBurst target) {
if (state != target) return false;
state = null;
return true;
}
}
final pendingReactionBurstProvider =
NotifierProvider<PendingReactionBurstNotifier, PendingReactionBurst?>(
PendingReactionBurstNotifier.new,
);
class _Particle {
double x;
double y;
double xv;
double yv;
double rotation;
double spin;
double scale;
double opacity;
double life;
final double maxLife;
final String emoji;
final double fontSize;
final double gravity;
_Particle({
required this.x,
required this.y,
required this.xv,
required this.yv,
required this.rotation,
required this.spin,
required this.scale,
required this.opacity,
required this.life,
required this.maxLife,
required this.emoji,
required this.fontSize,
required this.gravity,
});
/// Desktop's `updateParticle`. Returns false once the particle is spent.
bool step() {
life -= 1;
rotation += spin;
yv += gravity;
xv *= 0.965;
yv *= 0.998;
x += xv;
y += yv;
scale += (1 - scale) * 0.28;
final lifeRatio = life / maxLife;
if (lifeRatio < 0.24) {
opacity = max(0, lifeRatio / 0.24);
}
return life > 0 && opacity > 0.02;
}
}
/// Holds the live particles and notifies the painter each step.
///
/// Deliberately not a `Notifier` over a particle list: the particles mutate 60
/// times a second and must never rebuild a widget. Listeners are the painter's
/// repaint signal only.
class EmojiBurstController extends ChangeNotifier {
final List<_Particle> _particles = [];
final Random _random;
/// Set when a spawn arrives so the overlay knows to start its ticker.
VoidCallback? onSpawn;
EmojiBurstController({Random? random}) : _random = random ?? Random();
bool get hasParticles => _particles.isNotEmpty;
/// Spawn a burst of [emoji] centred on [origin] (global coordinates).
/// Mirrors desktop's `spawnPickerEmojiBurst`.
void burst(String emoji, Offset origin) {
final trimmed = emoji.trim();
if (trimmed.isEmpty) return;
if (_particles.length + _particlesPerBurst > _maxActiveParticles) return;
for (var i = 0; i < _particlesPerBurst; i += 1) {
final horizontalDrift = (_random.nextDouble() - 0.5) * 4.4;
final initialLift = 2.1 + _random.nextDouble() * 2.35;
_particles.add(
_Particle(
x: origin.dx,
y: origin.dy,
xv: horizontalDrift,
yv: -initialLift,
rotation: (_random.nextDouble() - 0.5) * 22,
spin: (_random.nextDouble() - 0.5) * 5.2,
scale: 0.25,
opacity: 1,
life: _particleLifeFrames.toDouble(),
maxLife: _particleLifeFrames.toDouble(),
emoji: trimmed,
fontSize: 18 + (_random.nextDouble() * 24).ceilToDouble(),
// Negative: the burst floats up and keeps accelerating upward.
gravity: -(0.018 + _random.nextDouble() * 0.018),
),
);
}
onSpawn?.call();
notifyListeners();
}
/// Advance one fixed step. Returns whether any particles remain.
bool step() {
for (var i = _particles.length - 1; i >= 0; i -= 1) {
if (!_particles[i].step()) {
_particles[i] = _particles.last;
_particles.removeLast();
}
}
notifyListeners();
return _particles.isNotEmpty;
}
void clear() {
if (_particles.isEmpty) return;
_particles.clear();
notifyListeners();
}
@override
void dispose() {
_particles.clear();
super.dispose();
}
}
final emojiBurstControllerProvider = Provider<EmojiBurstController>((ref) {
final controller = EmojiBurstController();
ref.onDispose(controller.dispose);
return controller;
});
/// Fire a burst of [emoji] centred on [context]'s own box.
///
/// The convenience the call sites actually want: they have a build context for
/// the pill or tile that was tapped and no reason to know about global
/// coordinates. Silently does nothing for emoji outside the positive set or
/// when the platform asks for reduced motion, so callers stay simple.
void burstEmojiFromContext(
WidgetRef ref,
BuildContext context,
String emoji, {
bool requirePositive = true,
}) {
if (requirePositive && !isPositiveEmojiParticle(emoji)) return;
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) return;
final box = context.findRenderObject();
if (box is! RenderBox || !box.hasSize) return;
final origin = box.localToGlobal(box.size.center(Offset.zero));
ref.read(emojiBurstControllerProvider).burst(emoji, origin);
}
/// Full-screen particle layer. Install once, above the navigator, so bursts
/// keep playing over pushed routes and modal sheets.
class EmojiBurstOverlay extends HookConsumerWidget {
final Widget child;
const EmojiBurstOverlay({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(emojiBurstControllerProvider);
final tickerProvider = useSingleTickerProvider();
// Ticker + leftover time live in refs: stepping physics must not rebuild
// this widget, which wraps the entire app.
final leftover = useRef(0.0);
final lastElapsed = useRef(Duration.zero);
final ticker = useMemoized(() {
late final Ticker created;
created = tickerProvider.createTicker((elapsed) {
final delta = elapsed - lastElapsed.value;
lastElapsed.value = elapsed;
leftover.value += delta.inMicroseconds / Duration.microsecondsPerSecond;
var steps = 0;
var alive = controller.hasParticles;
while (leftover.value >= _stepSeconds && steps < _maxStepsPerFrame) {
leftover.value -= _stepSeconds;
steps += 1;
alive = controller.step();
if (!alive) break;
}
// Don't bank unspent time from a stall — it would fast-forward the
// next burst.
if (steps >= _maxStepsPerFrame) leftover.value = 0;
if (!alive) {
created.stop();
lastElapsed.value = Duration.zero;
leftover.value = 0;
}
});
return created;
}, [tickerProvider, controller]);
useEffect(() => ticker.dispose, [ticker]);
useEffect(() {
void start() {
if (ticker.isActive) return;
lastElapsed.value = Duration.zero;
leftover.value = 0;
ticker.start();
}
controller.onSpawn = start;
if (controller.hasParticles) start();
return () {
if (controller.onSpawn == start) controller.onSpawn = null;
};
}, [ticker, controller]);
return Stack(
children: [
child,
Positioned.fill(
child: IgnorePointer(
child: ExcludeSemantics(
child: RepaintBoundary(
child: CustomPaint(painter: _EmojiBurstPainter(controller)),
),
),
),
),
],
);
}
}
/// Laid-out glyphs, keyed by emoji. Emoji are drawn thousands of times per
/// burst; laying out the text once and scaling the canvas is the whole trick.
final Map<String, TextPainter> _glyphCache = {};
/// Bounded so a chatty channel full of distinct reactions can't grow it
/// without limit. Bursts are visual sugar — evicting the whole cache costs one
/// re-layout per emoji still on screen.
const _glyphCacheLimit = 64;
TextPainter _glyphFor(String emoji) {
final cached = _glyphCache[emoji];
if (cached != null) return cached;
if (_glyphCache.length >= _glyphCacheLimit) _glyphCache.clear();
final painter = TextPainter(
text: TextSpan(
text: emoji,
style: const TextStyle(fontSize: _glyphCachePx),
),
textDirection: TextDirection.ltr,
)..layout();
_glyphCache[emoji] = painter;
return painter;
}
/// Drop the cached glyph layouts. Exposed for tests; the cache is not
/// community- or account-scoped, so nothing else needs to reset it.
@visibleForTesting
void clearEmojiBurstGlyphCache() => _glyphCache.clear();
class _EmojiBurstPainter extends CustomPainter {
final EmojiBurstController controller;
_EmojiBurstPainter(this.controller) : super(repaint: controller);
@override
void paint(Canvas canvas, Size size) {
for (final particle in controller._particles) {
final glyph = _glyphFor(particle.emoji);
final drawSize = particle.fontSize * particle.scale;
final scale = drawSize / _glyphCachePx;
canvas.save();
canvas.translate(particle.x, particle.y);
canvas.rotate(particle.rotation * pi / 180);
canvas.scale(scale);
// Color fonts ignore TextStyle.color, so fade through a layer instead.
if (particle.opacity < 1) {
canvas.saveLayer(
Rect.fromCenter(
center: Offset.zero,
width: glyph.width,
height: glyph.height,
),
Paint()..color = Colors.black.withValues(alpha: particle.opacity),
);
}
glyph.paint(canvas, Offset(-glyph.width / 2, -glyph.height / 2));
if (particle.opacity < 1) canvas.restore();
canvas.restore();
}
}
@override
bool shouldRepaint(_EmojiBurstPainter oldDelegate) =>
oldDelegate.controller != controller;
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/foundation.dart';
/// The standard (Unicode) emoji set, projected from the same emoji-mart dataset
/// desktop uses.
///
/// The asset at `assets/emoji/emoji-data.json` is generated by
/// `just mobile-emoji-data`; ids here are emoji-mart ids, which are exactly the
/// `:shortcode:` values desktop emits from its picker and resolves in
/// `emojiDisplayName`. Keeping both clients on one dataset is what stops a
/// shortcode from meaning different things on mobile and desktop.
@immutable
class EmojiEntry {
/// emoji-mart id, i.e. the shortcode without the surrounding colons.
final String id;
/// Human-readable name, e.g. `Index Pointing Up`.
final String name;
/// Search keywords from the dataset.
final List<String> keywords;
/// The default-skin glyph.
final String native;
/// Position within emoji-mart's skin list. The default skin is zero.
final int skinIndex;
/// Owning category id, e.g. `people`.
final String categoryId;
const EmojiEntry({
required this.id,
required this.name,
required this.keywords,
required this.native,
required this.categoryId,
this.skinIndex = 0,
});
/// Unique tile id while preserving the desktop-identical shortcode for the
/// default skin.
String get tileId => skinIndex == 0 ? id : '$id-$skinIndex';
}
/// One dataset category, in emoji-mart's own order.
@immutable
class EmojiCategory {
final String id;
final List<EmojiEntry> emoji;
const EmojiCategory({required this.id, required this.emoji});
/// Title-cased label for the category rail.
String get label => switch (id) {
'people' => 'Smileys & People',
'nature' => 'Animals & Nature',
'foods' => 'Food & Drink',
'activity' => 'Activity',
'places' => 'Travel & Places',
'objects' => 'Objects',
'symbols' => 'Symbols',
'flags' => 'Flags',
_ => id,
};
}
/// Parsed emoji dataset: ordered categories, a flat list for search, and a
/// reverse glyph index for resolving a reaction back to its shortcode.
@immutable
class EmojiDataset {
final List<EmojiCategory> categories;
/// Every entry, in category order. Search scans this.
final List<EmojiEntry> all;
/// Glyph to `:shortcode:`. Mirrors desktop's `emojiDisplayName`.
final Map<String, String> nativeToShortcode;
const EmojiDataset({
required this.categories,
required this.all,
required this.nativeToShortcode,
});
static const empty = EmojiDataset(
categories: [],
all: [],
nativeToShortcode: {},
);
bool get isEmpty => all.isEmpty;
/// Resolve a reaction value to something displayable as a name: a custom
/// emoji's `:shortcode:` passes through, a Unicode glyph maps to its
/// shortcode, and anything unknown falls back to itself.
String displayName(String emoji) {
final trimmed = emoji.trim();
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return trimmed;
return nativeToShortcode[trimmed] ?? trimmed;
}
/// Parse the generated asset. Runs on a background isolate via `compute`, so
/// it must stay a top-level-callable pure function over plain JSON.
static EmojiDataset fromJson(Map<String, dynamic> json) {
final rawEmoji = (json['emoji'] as Map).cast<String, dynamic>();
final rawCategories = (json['categories'] as List).cast<dynamic>();
final categories = <EmojiCategory>[];
final all = <EmojiEntry>[];
final nativeToShortcode = <String, String>{};
for (final rawCategory in rawCategories) {
final category = (rawCategory as Map).cast<String, dynamic>();
final categoryId = category['id'] as String;
final entries = <EmojiEntry>[];
for (final rawId in (category['emoji'] as List).cast<dynamic>()) {
final id = rawId as String;
final record = (rawEmoji[id] as Map?)?.cast<String, dynamic>();
if (record == null) continue;
// Older committed assets used one string; accept that shape so the
// parser remains safe while generated assets move to all skin variants.
final natives = switch (record['u']) {
final List values => values.cast<String>(),
final String value => [value],
_ => const <String>[],
};
for (final (skinIndex, native) in natives.indexed) {
final entry = EmojiEntry(
id: id,
name: record['n'] as String,
keywords: (record['k'] as List).cast<String>(),
native: native,
categoryId: categoryId,
skinIndex: skinIndex,
);
entries.add(entry);
all.add(entry);
// First writer wins so the earliest category owns a shared glyph,
// matching desktop's map build order.
nativeToShortcode.putIfAbsent(entry.native, () => ':$id:');
}
}
categories.add(EmojiCategory(id: categoryId, emoji: entries));
}
return EmojiDataset(
categories: categories,
all: all,
nativeToShortcode: nativeToShortcode,
);
}
}
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'emoji_data.dart';
/// Asset path for the generated dataset. See `mobile/scripts/generate-emoji-data.mjs`.
const emojiDataAssetPath = 'assets/emoji/emoji-data.json';
/// Decode + parse off the UI isolate. ~1.9k entries is fast but not free, and
/// the picker sheet animates in while this runs.
EmojiDataset _parseEmojiData(String source) {
return EmojiDataset.fromJson(jsonDecode(source) as Map<String, dynamic>);
}
/// The standard emoji dataset, loaded once from the app bundle.
final emojiDatasetProvider = FutureProvider<EmojiDataset>((ref) async {
final source = await rootBundle.loadString(emojiDataAssetPath);
return compute(_parseEmojiData, source);
});
/// Synchronous read of the dataset — [EmojiDataset.empty] while loading or on
/// error. Convenience for widgets that only need the resolved value, mirroring
/// `customEmojiListProvider`.
final emojiDatasetOrEmptyProvider = Provider<EmojiDataset>((ref) {
return ref.watch(emojiDatasetProvider).value ?? EmojiDataset.empty;
});
/// Every glyph in the dataset, as a set for membership tests.
///
/// Built once per dataset load rather than per message: `isEmojiOnlyMessage`
/// runs on every rendered body, and materializing ~1.9k keys there would be a
/// per-frame cost.
final nativeEmojiGlyphsProvider = Provider<Set<String>>((ref) {
return ref.watch(emojiDatasetOrEmptyProvider).nativeToShortcode.keys.toSet();
});
+87
View File
@@ -0,0 +1,87 @@
/// Detection and sizing for messages whose entire body is emoji, ported from
/// desktop's `isEmojiOnlyMessage` (`desktop/src/shared/lib/emojiOnly.ts`) and
/// the `emojiOnly` branch of `MessageRow.tsx`.
library;
import 'package:characters/characters.dart';
import '../custom_emoji/custom_emoji.dart';
/// Body size for an emoji-only message. Desktop steps `text-sm` (14px) up to
/// `text-4xl`; mobile body text is the same 14px, so the same 36px lands the
/// same jump.
const double kEmojiOnlyFontSize = 36.0;
/// Line height for an emoji-only body — desktop's `leading-tight`. Emoji have
/// generous internal leading already, so normal line spacing leaves a gap that
/// reads as a blank row.
const double kEmojiOnlyHeight = 1.25;
/// Inline custom-emoji size inside an emoji-only message. Desktop sizes these
/// at `1.45em` of the surrounding text.
const double kEmojiOnlyCustomEmojiSize = kEmojiOnlyFontSize * 1.45;
/// Anything with `Extended_Pictographic` in it is emoji enough. Keycaps and
/// digit-based sequences don't match — those are covered by the dataset's own
/// native set instead, exactly as desktop does it.
final RegExp _pictographic = RegExp(
r'\p{Extended_Pictographic}',
unicode: true,
);
/// Whether [content] is nothing but emoji — native glyphs, known custom
/// `:shortcode:`s, and whitespace, with at least one emoji present.
///
/// [nativeEmoji] is the dataset's glyph set — read `nativeEmojiGlyphsProvider`.
/// Passing an empty set still gives correct results for ordinary emoji, since
/// the pictographic check stands on its own; only glyphs with no pictographic
/// code point of their own (keycaps like 1️⃣) need the set.
bool isEmojiOnlyMessage(
String content, {
required Set<String> nativeEmoji,
List<CustomEmoji> customEmoji = const [],
}) {
final trimmed = content.trim();
if (trimmed.isEmpty) return false;
final shortcodes = {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(),
};
var sawEmoji = false;
// Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop
// hand-rolls this scan; Dart's `characters` already segments correctly.
final iterator = trimmed.characters.iterator;
final clusters = <String>[];
while (iterator.moveNext()) {
clusters.add(iterator.current);
}
var index = 0;
while (index < clusters.length) {
final cluster = clusters[index];
if (cluster.trim().isEmpty) {
index += 1;
continue;
}
if (cluster == ':') {
final end = clusters.indexOf(':', index + 1);
if (end <= index + 1) return false;
final shortcode = clusters.sublist(index + 1, end).join().toLowerCase();
if (!shortcodes.contains(shortcode)) return false;
sawEmoji = true;
index = end + 1;
continue;
}
if (!nativeEmoji.contains(cluster) && !_pictographic.hasMatch(cluster)) {
return false;
}
sawEmoji = true;
index += 1;
}
return sawEmoji;
}
+214
View File
@@ -0,0 +1,214 @@
/// Emoji search for the mobile picker and reaction tray.
///
/// Ported from `desktop/src/shared/lib/emojiSearch.ts`, which exists because
/// emoji-mart's own search is token-prefix based and so can't cross the `_` in
/// a shortcode — `pointup` never finds `point_up`. The tiers keep ordinary
/// queries ranked the way you'd expect while still surfacing looser matches
/// last: exact > prefix > substring > subsequence over the shortcode.
///
/// Mobile extends the desktop tiers with name/keyword matching, because the
/// tray replaces emoji-mart's picker (which searched names and keywords) rather
/// than just its `:shortcode` autocomplete. Shortcode hits still outrank
/// keyword hits, so typing a shortcode you know never buries it.
library;
import 'emoji_data.dart';
// Lower tier = stronger match.
const _tierExact = 0;
const _tierShortcodePrefix = 1;
const _tierKeywordPrefix = 2;
const _tierShortcodeSubstring = 3;
const _tierKeywordSubstring = 4;
const _tierSubsequence = 5;
/// Sentinel ranking worse than every real tier.
const _noMatch = 1 << 20;
final _separators = RegExp(r'[:_\s-]');
final _wordSplit = RegExp(r'[\s_-]+');
/// Lowercase and drop separators (`:`, `_`, `-`, whitespace) so matching is
/// insensitive to shortcode punctuation. `point_up` and `pointup` collapse to
/// the same normalized form.
///
/// Named for what it does rather than desktop's `normalizeShortcode`, because
/// `custom_emoji.dart` already exports a validating `normalizeShortcode` with
/// different semantics (it returns null for malformed input).
String collapseSeparators(String value) {
return value.toLowerCase().replaceAll(_separators, '');
}
/// A ranked match. [score] is a within-tier tiebreak — lower is better.
class EmojiMatch {
final int tier;
final int score;
const EmojiMatch({required this.tier, required this.score});
}
/// If [query] is a subsequence of [target] (all chars in order, gaps allowed),
/// return the span of the match as a tightness score — smaller is tighter.
/// Returns null when it isn't a subsequence.
int? _subsequenceSpan(String query, String target) {
var first = -1;
var last = -1;
var qi = 0;
for (var ti = 0; ti < target.length && qi < query.length; ti++) {
if (target[ti] == query[qi]) {
if (first == -1) first = ti;
last = ti;
qi++;
}
}
if (qi < query.length) return null;
return last - first;
}
/// Score [query] against a single [shortcode]. Returns null when there is no
/// match at any tier. Both sides are normalized first, so separators are
/// ignored. This is the desktop-identical half of the ranking.
EmojiMatch? scoreShortcodeMatch(String query, String shortcode) {
final q = collapseSeparators(query);
if (q.isEmpty) return null;
final target = collapseSeparators(shortcode);
if (target.isEmpty) return null;
if (q == target) return const EmojiMatch(tier: _tierExact, score: 0);
if (target.startsWith(q)) {
return const EmojiMatch(tier: _tierShortcodePrefix, score: 0);
}
final index = target.indexOf(q);
if (index != -1) {
return EmojiMatch(tier: _tierShortcodeSubstring, score: index);
}
final span = _subsequenceSpan(q, target);
if (span != null) return EmojiMatch(tier: _tierSubsequence, score: span);
return null;
}
/// Score [query] against an entry's name and keywords, word by word. Used to
/// top up shortcode matching so a query like `smiling` finds emoji whose
/// shortcode doesn't contain it.
EmojiMatch? _scoreKeywordMatch(
String query,
String name,
List<String> keywords,
) {
final q = query.toLowerCase().trim();
if (q.isEmpty) return null;
var bestTier = _noMatch;
var bestScore = 0;
// Earlier words rank better: the name comes before the keywords, and within
// each the dataset's own order is meaningful.
void consider(String candidate, int wordIndex) {
if (bestTier == _tierKeywordPrefix) return;
final word = candidate.toLowerCase();
if (word.isEmpty) return;
final tier = word.startsWith(q)
? _tierKeywordPrefix
: word.contains(q)
? _tierKeywordSubstring
: _noMatch;
if (tier < bestTier) {
bestTier = tier;
bestScore = wordIndex;
}
}
var wordIndex = 0;
for (final word in name.split(_wordSplit)) {
consider(word, wordIndex++);
}
for (final keyword in keywords) {
for (final word in keyword.split(_wordSplit)) {
consider(word, wordIndex++);
}
}
if (bestTier == _noMatch) return null;
return EmojiMatch(tier: bestTier, score: bestScore);
}
class _Scored<T> {
final T item;
final int tier;
final int score;
final String code;
const _Scored({
required this.item,
required this.tier,
required this.score,
required this.code,
});
}
int _compare<T>(_Scored<T> a, _Scored<T> b) {
if (a.tier != b.tier) return a.tier - b.tier;
if (a.score != b.score) return a.score - b.score;
// Shorter shortcode is the more specific match, then alphabetical for a
// stable result.
if (a.code.length != b.code.length) return a.code.length - b.code.length;
return a.code.compareTo(b.code);
}
/// Rank [items] by how well their shortcode matches [query], best first, capped
/// at [limit]. Mirrors desktop's `rankByShortcode` — used for the custom-emoji
/// palette, which has no names or keywords.
List<T> rankByShortcode<T>(
String query,
List<T> items,
String Function(T item) shortcodeOf, {
int? limit,
}) {
if (limit != null && limit <= 0) return const [];
final scored = <_Scored<T>>[];
for (final item in items) {
final code = shortcodeOf(item);
final match = scoreShortcodeMatch(query, code);
if (match == null) continue;
scored.add(
_Scored(item: item, tier: match.tier, score: match.score, code: code),
);
}
scored.sort(_compare);
final ranked = scored.map((entry) => entry.item).toList();
if (limit == null || ranked.length <= limit) return ranked;
return ranked.sublist(0, limit);
}
/// Rank standard emoji by shortcode, name, and keywords, best first.
List<EmojiEntry> searchEmoji(
String query,
List<EmojiEntry> entries, {
int? limit,
}) {
if (limit != null && limit <= 0) return const [];
final scored = <_Scored<EmojiEntry>>[];
for (final entry in entries) {
final shortcode = scoreShortcodeMatch(query, entry.id);
final keyword = _scoreKeywordMatch(query, entry.name, entry.keywords);
final match = switch ((shortcode, keyword)) {
(null, null) => null,
(final EmojiMatch only, null) => only,
(null, final EmojiMatch only) => only,
(final EmojiMatch a, final EmojiMatch b) => a.tier <= b.tier ? a : b,
};
if (match == null) continue;
scored.add(
_Scored(
item: entry,
tier: match.tier,
score: match.score,
code: entry.id,
),
);
}
scored.sort(_compare);
final ranked = scored.map((entry) => entry.item).toList();
if (limit == null || ranked.length <= limit) return ranked;
return ranked.sublist(0, limit);
}
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// A standalone system emoji whose visual centre matches its surrounding UI.
///
/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the
/// layout box unchanged and lift only the painted glyph on iOS; Android's
/// system emoji metrics are already visually centred.
class NativeEmojiGlyph extends StatelessWidget {
final String emoji;
final double size;
const NativeEmojiGlyph({super.key, required this.emoji, required this.size});
@override
Widget build(BuildContext context) {
final glyph = Text(emoji, style: TextStyle(fontSize: size));
if (defaultTargetPlatform != TargetPlatform.iOS) return glyph;
return Transform.translate(offset: const Offset(0, -1), child: glyph);
}
}
@@ -0,0 +1,91 @@
/// The emoji that earn a particle burst.
///
/// Ported verbatim from `POSITIVE_EMOJI_PARTICLES` in
/// `desktop/src/shared/ui/EmojiBurstProvider.tsx`. Only affirmation reacts
/// celebrate — a 😢 or 👎 shouldn't throw confetti — so the three groups below
/// are the whole gate, and both clients must agree on them or the same reaction
/// bursts on one device and not the other.
library;
const heartParticleEmojis = <String>[
'\u{2764}\u{FE0F}', // ❤️
'\u{1FA77}', // 🩷
'\u{1F9E1}', // 🧡
'\u{1F49B}', // 💛
'\u{1F49A}', // 💚
'\u{1FA75}', // 🩵
'\u{1F499}', // 💙
'\u{1F49C}', // 💜
'\u{1F90E}', // 🤎
'\u{1F5A4}', // 🖤
'\u{1FA76}', // 🩶
'\u{1F90D}', // 🤍
'\u{2764}\u{FE0F}\u{200D}\u{1F525}', // ❤️‍🔥
'\u{2764}\u{FE0F}\u{200D}\u{1FA79}', // ❤️‍🩹
'\u{1F496}', // 💖
'\u{1F495}', // 💕
'\u{1F497}', // 💗
'\u{1F493}', // 💓
'\u{1F49E}', // 💞
'\u{1F498}', // 💘
'\u{1F49D}', // 💝
'\u{2763}\u{FE0F}', // ❣️
'\u{2665}\u{FE0F}', // ♥️
];
const positiveReactionParticleEmojis = <String>[
'\u{1F44D}', // 👍
'\u{1F44F}', // 👏
'\u{1F64C}', // 🙌
'\u{1F64F}', // 🙏
'\u{1F4AF}', // 💯
'\u{1F525}', // 🔥
'\u{2728}', // ✨
'\u{2B50}', // ⭐
'\u{1F31F}', // 🌟
'\u{1F389}', // 🎉
'\u{1F38A}', // 🎊
'\u{1F973}', // 🥳
'\u{1F680}', // 🚀
'\u{1F4AA}', // 💪
];
const positiveFaceParticleEmojis = <String>[
'\u{1F600}', // 😀
'\u{1F603}', // 😃
'\u{1F604}', // 😄
'\u{1F601}', // 😁
'\u{1F60A}', // 😊
'\u{1F607}', // 😇
'\u{1F642}', // 🙂
'\u{1F60D}', // 😍
'\u{1F929}', // 🤩
'\u{1F970}', // 🥰
'\u{1F618}', // 😘
'\u{1F60B}', // 😋
'\u{1F60E}', // 😎
'\u{1F602}', // 😂
'\u{1F923}', // 🤣
];
const positiveEmojiParticles = <String>[
...heartParticleEmojis,
...positiveReactionParticleEmojis,
...positiveFaceParticleEmojis,
];
/// Emoji presentation selectors carry no meaning for this comparison, and the
/// same emoji reaches us with and without one depending on where it came from
/// (the emoji-mart dataset, a hand-typed reaction, an older client). Desktop
/// gets away with exact set membership because its picker is the only producer;
/// mobile reactions also arrive off the wire, so fold the selector away.
String _foldSelectors(String emoji) =>
emoji.trim().replaceAll('\u{FE0F}', '').replaceAll('\u{FE0E}', '');
final Set<String> _positiveEmojiParticleSet = {
for (final emoji in positiveEmojiParticles) _foldSelectors(emoji),
};
/// Whether [emoji] should burst. Mirrors desktop's `isPositiveEmojiParticle`.
bool isPositiveEmojiParticle(String emoji) =>
_positiveEmojiParticleSet.contains(_foldSelectors(emoji));