From b80217de32673dc55c5949dd492578e9bdcb0d32 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 31 Oct 2026 09:30:23 -0700 Subject: [PATCH] fix(mobile): end #channel tokens at apostrophes, quotes and dashes (#8860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile renders `#channel` tokens as chips, but a token followed by an apostrophe, quote, ellipsis or dash stayed plain text: `#general's`, `"#general"`, `#general…` and `#general—` all lost their chip because the token only ended at whitespace or `, ; . ! ? : ) ] }`. This ends channel tokens at those characters too (`' " ‘ ’ “ ” » … – —`). The message body and the one-line preview each had their own copy of the channel regex; they now share one `channelTokenPattern`, so they can't drift apart again. `@mention` boundaries are unchanged on purpose: on mobile and desktop the same boundary also decides which mentions are bound and tagged when sending, so changing it is a separate, cross-surface decision. ### Validation Added tests: - Preview table covering each new trailing character (and matching opening quotes). - Message body: `#general's` keeps the chip and renders the `'s` suffix. Both fail with the previous boundary. The full mobile test suite passed in pre-push. Simulator render on an iPhone 18 Pro (iOS 27): real message-body and preview widgets with channels `general` and `eng` loaded, rendered with both stacked PRs applied. | Before (`main`) | After ([#8860](https://github.com/block/buzz/pull/8860) + [#8862](https://github.com/block/buzz/pull/8862)) | | --- | --- | | | | Human app test: not applicable under the mobile-client testing exception; agent review and simulator validation above. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow --- .../features/channels/message_content.dart | 1 + .../channel_token_pattern.dart | 30 +++++++++++++++++++ .../message_content/preview_text.dart | 20 ++----------- .../channels/message_content/token_pill.dart | 6 +--- .../message_content/preview_text_test.dart | 24 +++++++++++++++ .../channels/message_content_test.dart | 17 +++++++++++ 6 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/channel_token_pattern.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8efe3df23d..cce3b01737 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -39,6 +39,7 @@ import 'message_content/preview_text.dart'; import 'message_media.dart'; import 'message_content/bubble_segments.dart'; import 'message_content/buzz_link_label.dart'; +import 'message_content/channel_token_pattern.dart'; import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; diff --git a/mobile/lib/features/channels/message_content/channel_token_pattern.dart b/mobile/lib/features/channels/message_content/channel_token_pattern.dart new file mode 100644 index 0000000000..ae9b3a840a --- /dev/null +++ b/mobile/lib/features/channels/message_content/channel_token_pattern.dart @@ -0,0 +1,30 @@ +/// Matches inline `#channel` tokens. The message body and its one-line +/// preview share it so they always agree on what is a channel token. +library; + +/// Known [knownNames] match longest first, so `#eng-ios` wins over `#eng`. +/// A token can't start inside a word, path or URL (`a#b`, `x.y/#z`), and it +/// ends at whitespace, the end of a line, or punctuation that commonly follows +/// a word in prose: `#general's`, `"#general"`, `#general…`, `#general—`. +RegExp channelTokenPattern(Iterable knownNames) { + final names = + knownNames + .map((name) => name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList() + ..sort((a, b) => b.length.compareTo(a.length)); + final alternatives = [...names.map(RegExp.escape), _genericName].join('|'); + return RegExp( + '$_leading#(?:$alternatives)$_trailing', + caseSensitive: false, + multiLine: true, + ); +} + +const _genericName = r'[A-Za-z0-9_][A-Za-z0-9_-]*'; +const _leading = r'(? customEmoji; final Map channelNames; final Map imetaByUrl; - late final RegExp _channel = _channelPattern(channelNames.keys); + late final RegExp _channel = channelTokenPattern(channelNames.keys); final pieces = []; _PreviewBuilder({ @@ -485,20 +486,3 @@ class _PreviewBuilder { ); } } - -/// The thread view's channel token: a known name, which may contain -/// characters the generic token excludes, or any `#token`. -RegExp _channelPattern(Iterable knownNames) { - final names = - knownNames - .map((name) => name.trim()) - .where((name) => name.isNotEmpty) - .toSet() - .toList() - ..sort((a, b) => b.length.compareTo(a.length)); - const generic = r'[A-Za-z0-9_][A-Za-z0-9_-]*'; - const leading = r'(? channels, String channelId) { class _ChannelLinkMd extends InlineMd { final Map channelNames; final void Function(String channelId)? onChannelTap; - late final RegExp _exp = _buildPrefixPattern( - prefix: '#', - knownNames: channelNames.keys, - genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*', - ); + late final RegExp _exp = channelTokenPattern(channelNames.keys); _ChannelLinkMd({required this.channelNames, this.onChannelTap}); diff --git a/mobile/test/features/channels/message_content/preview_text_test.dart b/mobile/test/features/channels/message_content/preview_text_test.dart index 0d194ca547..50b632a571 100644 --- a/mobile/test/features/channels/message_content/preview_text_test.dart +++ b/mobile/test/features/channels/message_content/preview_text_test.dart @@ -215,6 +215,30 @@ void main() { ); }); + test('channel tokens end at punctuation that follows a word', () { + for (final (before, after) in [ + ('', "'s plan"), + ('', '\u2019s plan'), + ('"', '"'), + ('\u201C', '\u201D'), + ('\u2018', '\u2019'), + ('\u00AB', '\u00BB'), + ('', '\u2026'), + ('', '\u2014next'), + ('', '\u2013next'), + ]) { + expect( + _pieces('see $before#general$after', channelNames: {'general': 'c1'}), + [ + PreviewText('see $before'), + const PreviewChannel('general'), + PreviewText(after), + ], + reason: '$before#general$after', + ); + } + }); + test('a buzz channel link becomes a chip with the channel name', () { expect( _pieces( diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index e5f9de0070..7c61988371 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -3068,6 +3068,23 @@ Photos expect(find.text('#unknown'), findsNothing); }); + testWidgets('possessive #channel keeps the pill and the suffix', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: "See #general's pinned plan", + channelNames: {'general': 'ch-id-1'}, + ), + ), + ); + + expect(find.byIcon(BuzzIcons.hash), findsOneWidget); + expect(find.text('general'), findsOneWidget); + expect(_allRichText(tester), contains("'s pinned plan")); + }); + testWidgets('does not treat URL fragments as channel links', ( tester, ) async {