Files
buzz/mobile/lib/shared/emoji/emoji_only.dart
klopez4212 85edc0572a feat(mobile): desktop-parity emoji and thread experience (#3485)
Brings the Flutter app's emoji and thread surfaces up to desktop parity.

## Emoji

- **Full emoji-mart dataset** generated from the same `@emoji-mart/data`
set desktop uses (1,870 emoji, 8 categories), committed as an asset — so
shortcodes, names, and keywords are identical across clients. `just
mobile-emoji-data` regenerates it.
- **Rebuilt the tray**: search (a Dart port of desktop's tiered
`emojiSearch` ranking, extended to names and keywords), a
frequently-used section, and one continuous scroll with pinned section
headers. The category rail is a shortcut into that list, not a page
switcher, and spans the full width the search field uses. Custom emoji
share the native glyph size and cell.
- **Reaction pills** match desktop's geometry, and the count shows at 1.
- **Emoji-only messages** render at 36px with 1.45em inline custom
emoji, matching desktop's `emojiOnly` treatment.
- **Positive-emoji burst** ported from desktop's `EmojiBurstProvider`,
suppressed under reduced motion.

## Threads

- **Top-down layout** — head first under the app bar, replies flowing
down, like desktop's thread panel. The old reversed list bottom-anchored
the content and jammed the head against the composer.
- **Tap a channel message to open its thread**; long-press still opens
the action sheet.
- **Live reactions.** The thread's relay query is one-shot and its
`kinds` filter carries only content rows, so a reaction event could not
reach an open thread at all, and `allMessages` was a snapshot frozen
when the route was pushed — a new pill only appeared after leaving and
re-entering, which refetched. The live channel events are now unioned
into the thread's list. The burst is also route-guarded, since the
channel timeline stays mounted underneath and was claiming it first.
- The `+` affordance follows the channel: replies stay bare until they
carry a reaction, and the head keeps a standing `+`.

## Keyboard

A deliberate downward drag past ~48px dismisses the keyboard; short
scrolls leave it alone. Applies to the channel list, the thread list,
and the compose bar (via a raw `Listener`, so it can't steal the field's
tap or selection drags).

True finger-tracking dismissal is out of scope — Flutter only offers
`manual`/`onDrag`, and 1:1 tracking needs a native `UIScrollView` proxy
plus Android's `WindowInsetsAnimationController`.

## Testing

`just mobile-check` and `just mobile-test` pass (965 tests). New
coverage for emoji search ranking, dataset parsing, the emoji-only
predicate, tray scroll/rail behavior, reaction pills and the burst, and
both thread fixes above. `just ci` green.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: npub12zsjdqx8dud99s9h47xmk9lq93vryf7zjrae8wdrmma52cg5yglseyulst <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
Signed-off-by: klopez4212 <klopez4212@gmail.com>
Co-authored-by: npub12zsjdqx8dud99s9h47xmk9lq93vryf7zjrae8wdrmma52cg5yglseyulst <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
2026-07-30 07:29:40 -07:00

88 lines
2.9 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 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;
}