Files
buzz/mobile/test/shared/emoji/emoji_only_test.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

82 lines
2.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/emoji/emoji_only.dart';
import 'package:flutter_test/flutter_test.dart';
const _customEmoji = [
CustomEmoji(shortcode: 'buzz', url: 'https://example.test/buzz.png'),
];
/// Keycaps carry no `Extended_Pictographic` code point of their own, so they
/// only pass via the dataset's glyph set — the one case that needs it.
const _keycapOne = '1\u{FE0F}\u{20E3}';
bool emojiOnly(
String content, {
Set<String> nativeEmoji = const {},
List<CustomEmoji> customEmoji = const [],
}) => isEmojiOnlyMessage(
content,
nativeEmoji: nativeEmoji,
customEmoji: customEmoji,
);
void main() {
group('isEmojiOnlyMessage', () {
test('accepts emoji, alone and in runs', () {
// Mirrors the cases in desktop's emojiOnly.test.mjs.
expect(emojiOnly('\u{1F600}'), isTrue);
expect(
emojiOnly('\u{1F600} \u{1F44D}\u{1F3FD}\n\u{2764}\u{FE0F}'),
isTrue,
);
expect(emojiOnly('\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}'), isTrue);
expect(
emojiOnly(
'\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
),
isTrue,
);
});
test('rejects anything with text in it', () {
expect(emojiOnly('hi \u{1F600}'), isFalse);
expect(emojiOnly('\u{1F600}!'), isFalse);
expect(emojiOnly('nice'), isFalse);
});
test('rejects empty and whitespace-only bodies', () {
expect(emojiOnly(''), isFalse);
expect(emojiOnly(' \n '), isFalse);
});
test('counts a known custom shortcode as emoji', () {
expect(emojiOnly(':buzz:', customEmoji: _customEmoji), isTrue);
expect(emojiOnly(':buzz: \u{1F600}', customEmoji: _customEmoji), isTrue);
expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse);
});
test('an unknown shortcode is just text', () {
// Desktop behaves the same: without the palette it renders literally, so
// it must not be scaled up as if it were a glyph.
expect(emojiOnly(':buzz:'), isFalse);
expect(emojiOnly(':nope:', customEmoji: _customEmoji), isFalse);
expect(emojiOnly(':', customEmoji: _customEmoji), isFalse);
expect(emojiOnly('::', customEmoji: _customEmoji), isFalse);
});
test('keycaps need the dataset glyph set', () {
expect(emojiOnly(_keycapOne), isFalse);
expect(emojiOnly(_keycapOne, nativeEmoji: {_keycapOne}), isTrue);
});
});
group('emoji-only sizing', () {
test('matches desktop’s step up from body text', () {
// Desktop goes text-sm (14px) to text-4xl; mobile body is the same 14px.
expect(kEmojiOnlyFontSize, 36.0);
// Desktop sizes inline custom emoji at 1.45em of the surrounding text.
expect(kEmojiOnlyCustomEmojiSize, closeTo(36.0 * 1.45, 0.001));
});
});
}