mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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'])';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user