Files
buzz/mobile/lib/shared/emoji/emoji_data.dart
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

155 lines
4.9 KiB
Dart

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,
);
}
}