Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -292,7 +296,9 @@ class MessageContent extends HookConsumerWidget {
channelNames: resolvedChannelNames,
onChannelTap: resolvedChannelTap,
),
...MarkdownComponent.inlineComponents,
...MarkdownComponent.inlineComponents.where(
(component) => component is! ATagMd,
),
],
),
);
Expand Down
117 changes: 116 additions & 1 deletion mobile/lib/features/channels/message_content/token_pill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ String? _channelNameForId(Map<String, String> channels, String channelId) {
class _ChannelLinkMd extends InlineMd {
final Map<String, String> 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});
Expand Down Expand Up @@ -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'(?<!\!)\[.*\]\([^\s]*\)');

@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
var bracketCount = 0;
const start = 1;
var end = 0;
for (var i = 0; i < text.length; i++) {
if (text[i] == '[') {
bracketCount++;
} else if (text[i] == ']') {
bracketCount--;
if (bracketCount == 0) {
end = i;
break;
}
}
}

if (end + 1 >= 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<String> knownNames,
Expand Down
57 changes: 57 additions & 0 deletions mobile/test/features/channels/message_content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down