fix(mobile): end #channel tokens at apostrophes, quotes and dashes (#8860)

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)) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--01-before-main.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--02-after-both-prs.png"
width="300"> |

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-31 09:30:23 -07:00
committed by GitHub
parent d14f33a170
commit b80217de32
6 changed files with 75 additions and 23 deletions

View File

@@ -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';

View File

@@ -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<String> 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'(?<![\w./:-])';
const _trailing =
r'(?=$|[\s,;.!?:)\]}'
"'\"\u2018\u2019\u201C\u201D\u00BB\u2026\u2013\u2014"
r'])';

View File

@@ -6,6 +6,7 @@ import '../../../shared/deeplink/deep_link.dart';
import '../../../shared/mentions/mention_bindings.dart';
import '../message_media.dart';
import 'buzz_link_label.dart';
import 'channel_token_pattern.dart';
import 'link_normalizer.dart';
/// One piece of a message preview: plain text or an inline token that renders
@@ -291,7 +292,7 @@ class _PreviewBuilder {
final Map<String, String> customEmoji;
final Map<String, String> channelNames;
final Map<String, ImetaEntry> imetaByUrl;
late final RegExp _channel = _channelPattern(channelNames.keys);
late final RegExp _channel = channelTokenPattern(channelNames.keys);
final pieces = <PreviewPiece>[];
_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<String> 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'(?<![\w./:-])';
const trailing = r'(?=$|[\s,;.!?:)\]}])';
final alternatives = [...names.map(RegExp.escape), generic].join('|');
return RegExp('$leading#(?:$alternatives)$trailing', caseSensitive: false);
}

View File

@@ -10,11 +10,7 @@ String? _channelNameForId(Map<String, String> channels, String channelId) {
class _ChannelLinkMd extends InlineMd {
final Map<String, String> 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});

View File

@@ -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(

View File

@@ -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 {