diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 64938b5c879..cade0ba16c5 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -282,6 +282,10 @@ class MessageContent extends HookConsumerWidget { textAlign: textAlign, maxLines: maxLines, inlineComponents: [ + // Own ATag first so link *labels* never re-enter mention/channel/ + // emoji components (those would turn `[#2959](url)` into an empty + // nested WidgetSpan on device). Drop the stock ATagMd below. + _AuthoredMarkdownLinkMd(), _MentionMd( mentionNames: resolvedMentionNames, agentMentionPubkeys: resolvedAgentMentionPubkeys, @@ -292,7 +296,9 @@ class MessageContent extends HookConsumerWidget { channelNames: resolvedChannelNames, onChannelTap: resolvedChannelTap, ), - ...MarkdownComponent.inlineComponents, + ...MarkdownComponent.inlineComponents.where( + (component) => component is! ATagMd, + ), ], ), ); diff --git a/mobile/lib/features/channels/message_content/token_pill.dart b/mobile/lib/features/channels/message_content/token_pill.dart index 8c812d9b958..25217b6a15f 100644 --- a/mobile/lib/features/channels/message_content/token_pill.dart +++ b/mobile/lib/features/channels/message_content/token_pill.dart @@ -10,10 +10,13 @@ String? _channelNameForId(Map channels, String channelId) { class _ChannelLinkMd extends InlineMd { final Map channelNames; final void Function(String channelId)? onChannelTap; + // Require at least one letter/underscore so digits-only tokens like + // `#2959` (common GitHub issue labels) are never claimed as channels. late final RegExp _exp = _buildPrefixPattern( prefix: '#', knownNames: channelNames.keys, - genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*', + genericTokenPattern: + r'(?=[A-Za-z0-9_-]*[A-Za-z_])[A-Za-z0-9_][A-Za-z0-9_-]*', ); _ChannelLinkMd({required this.channelNames, this.onChannelTap}); @@ -104,6 +107,118 @@ class _TokenPill extends StatelessWidget { } } +/// Markdown `[label](url)` that keeps custom mention/channel/emoji components +/// out of the *label*. gpt_markdown's default [ATagMd] re-runs the full +/// `inlineComponents` list on the label, so `_ChannelLinkMd` used to swallow +/// labels like `#2959` into a token pill and leave the link blank on device. +class _AuthoredMarkdownLinkMd extends InlineMd { + @override + RegExp get exp => RegExp(r'(?= text.length || text[end + 1] != '(') { + return const TextSpan(); + } + + final linkText = text.substring(start, end); + final urlStart = end + 2; + + var parenCount = 0; + var urlEnd = urlStart; + for (var i = urlStart; i < text.length; i++) { + final char = text[i]; + if (char == '(') { + parenCount++; + } else if (char == ')') { + if (parenCount == 0) { + urlEnd = i; + break; + } + parenCount--; + } + } + + if (urlEnd == urlStart) { + return const TextSpan(); + } + + final url = text.substring(urlStart, urlEnd).trim(); + final builder = config.linkBuilder; + final ending = text.substring(urlEnd + 1); + final endingSpans = MarkdownComponent.generate( + context, + ending, + config, + false, + ); + final theme = GptMarkdownTheme.of(context); + + // Formatting (bold/italic/…) stays; mention/channel/emoji pills do not. + final labelConfig = config.copyWith( + inlineComponents: MarkdownComponent.inlineComponents + .where((component) => component is! ATagMd) + .toList(growable: false), + ); + final linkTextSpan = TextSpan( + children: MarkdownComponent.generate( + context, + linkText, + labelConfig, + false, + ), + style: config.style?.copyWith( + color: theme.linkColor, + decorationColor: theme.linkColor, + ), + ); + + WidgetSpan? child; + if (builder != null) { + child = WidgetSpan( + baseline: TextBaseline.alphabetic, + alignment: PlaceholderAlignment.baseline, + child: GestureDetector( + onTap: () => config.onLinkTap?.call(url, linkText), + child: builder( + context, + linkTextSpan, + url, + config.style ?? const TextStyle(), + ), + ), + ); + } + + child ??= WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: config.getRich(linkTextSpan), + ); + + return TextSpan(children: [child, ...endingSpans]); + } +} + RegExp _buildPrefixPattern({ required String prefix, required Iterable knownNames, diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index c4d624b22d7..d02f56c542f 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -2072,6 +2072,63 @@ Photos expect(_allRichText(tester), contains('https://example.com/docs#frag')); expect(find.text('#frag'), findsNothing); }); + + testWidgets('digits-only bare #token is not a channel pill', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Issue #2959 is open', + channelNames: {}, + ), + ), + ); + + expect(find.byIcon(LucideIcons.hash), findsNothing); + expect(_allRichText(tester), contains('#2959')); + }); + }); + + group('authored markdown links with # labels', () { + testWidgets('keeps #-prefixed link labels visible (not channel pills)', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'See [#2959](https://github.com/block/buzz/issues/2959) for details.', + channelNames: {}, + ), + ), + ); + + expect(find.byIcon(LucideIcons.hash), findsNothing); + expect(find.textContaining('#2959'), findsOneWidget); + expect(_allRichText(tester), contains('#2959')); + // Surrounding prose must still be there — the old bug left only + // punctuation: "See for details." + expect(_allRichText(tester), contains('See')); + expect(_allRichText(tester), contains('for details')); + }); + + testWidgets('known channel name inside a link label stays plain text', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Docs: [#general](https://example.com/general)', + channelNames: {'general': 'ch-id-1'}, + ), + ), + ); + + // Must not promote the label into a channel pill (hash icon). + expect(find.byIcon(LucideIcons.hash), findsNothing); + expect(find.textContaining('#general'), findsOneWidget); + }); }); group('mixed content', () {