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