diff --git a/mobile/lib/shared/emoji/emoji_only.dart b/mobile/lib/shared/emoji/emoji_only.dart index e6994f0b9b..97a945b78f 100644 --- a/mobile/lib/shared/emoji/emoji_only.dart +++ b/mobile/lib/shared/emoji/emoji_only.dart @@ -44,35 +44,35 @@ bool isEmojiOnlyMessage( final trimmed = content.trim(); if (trimmed.isEmpty) return false; - final shortcodes = { - for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), - }; + Set? shortcodes; 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 = []; while (iterator.moveNext()) { - clusters.add(iterator.current); - } + final cluster = iterator.current; - var index = 0; - while (index < clusters.length) { - final cluster = clusters[index]; - - if (cluster.trim().isEmpty) { - index += 1; - continue; - } + if (cluster.trim().isEmpty) 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; + final shortcode = StringBuffer(); + var closed = false; + while (iterator.moveNext()) { + if (iterator.current == ':') { + closed = true; + break; + } + shortcode.write(iterator.current); + } + if (!closed || shortcode.isEmpty) return false; + shortcodes ??= { + for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), + }; + if (!shortcodes.contains(shortcode.toString().toLowerCase())) { + return false; + } sawEmoji = true; - index = end + 1; continue; } @@ -80,7 +80,6 @@ bool isEmojiOnlyMessage( return false; } sawEmoji = true; - index += 1; } return sawEmoji; diff --git a/mobile/test/shared/emoji/emoji_only_test.dart b/mobile/test/shared/emoji/emoji_only_test.dart index 261b7d7cf1..10b7703db1 100644 --- a/mobile/test/shared/emoji/emoji_only_test.dart +++ b/mobile/test/shared/emoji/emoji_only_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -55,6 +57,34 @@ void main() { expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse); }); + test('scans consecutive shortcodes without skipping following text', () { + expect(emojiOnly(':BUZZ::buzz:', customEmoji: _customEmoji), isTrue); + expect(emojiOnly('😀:buzz:😀', customEmoji: _customEmoji), isTrue); + expect( + emojiOnly(':buzz:ordinary text', customEmoji: _customEmoji), + isFalse, + ); + expect( + emojiOnly(':buzz::unfinished', customEmoji: _customEmoji), + isFalse, + ); + expect(emojiOnly(':buzz:::', customEmoji: _customEmoji), isFalse); + }); + + test('does not inspect the community palette without a shortcode', () { + final palette = _CountingEmojiList(); + expect( + emojiOnly('Ordinary message text. ' * 500, customEmoji: palette), + isFalse, + ); + expect(emojiOnly('😀 👋', customEmoji: palette), isTrue); + expect(emojiOnly('text :buzz:', customEmoji: palette), isFalse); + expect(emojiOnly(':unfinished', customEmoji: palette), isFalse); + expect(palette.reads, 0); + expect(emojiOnly(':buzz::BUZZ:', customEmoji: palette), isTrue); + expect(palette.reads, palette.length); + }); + 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. @@ -79,3 +109,26 @@ void main() { }); }); } + +class _CountingEmojiList extends ListBase { + int reads = 0; + + @override + int get length => 2580; + + @override + set length(int value) => throw UnsupportedError('read-only fixture'); + + @override + CustomEmoji operator [](int index) { + reads += 1; + return CustomEmoji( + shortcode: index == 0 ? 'buzz' : 'unused_$index', + url: 'https://example.test/emoji.png', + ); + } + + @override + void operator []=(int index, CustomEmoji value) => + throw UnsupportedError('read-only fixture'); +}