mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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>
82 lines
2.8 KiB
Dart
82 lines
2.8 KiB
Dart
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));
|
||
});
|
||
});
|
||
}
|