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