Describe the bug
On iOS, a Markdown link whose label starts with # renders as nothing at all — the label, the underline, and the tap target are all gone. Only the surrounding punctuation survives.
A message body like
Dasselbe Muster gibt es dreimal ([#2959](https://github.com/block/buzz/issues/2959), [#2868](https://github.com/block/buzz/issues/2868), [#5409](https://github.com/block/buzz/issues/5409)).
renders on iOS as:
Dasselbe Muster gibt es dreimal (, , ).
The same message renders correctly on Buzz Desktop (macOS), with three underlined, tappable links.
Steps to reproduce
- From Buzz Desktop, post a message into a channel containing
See [#2959](https://github.com/block/buzz/issues/2959) for details.
- Open the same message in the iOS app (App Store 0.10.0).
- The link is invisible; the sentence reads
See for details.
Expected behavior
The link renders with its authored label #2959 and opens the URL on tap, as it does on desktop.
Actual behavior
Nothing is drawn where the link should be.
Version and platform
- iOS app: 0.10.0 (App Store, released 2026-08-13)
- Desktop for contrast: renders correctly
- Analysis below against
main @ f956e6f
Analysis
The link label is being consumed by the #channel inline component instead of being rendered as link text.
MessageContent registers three custom inline components ahead of the gpt_markdown defaults, one of which is _ChannelLinkMd — mobile/lib/features/channels/message_content.dart:284-296.
- gpt_markdown's
ATagMd.span renders a link's label by recursing: MarkdownComponent.generate(context, linkText, config, false), which resolves components from config.inlineComponents (gpt_markdown 1.1.6, lib/markdown_component.dart:41-44 and :858-864). So the custom components run inside link labels.
_ChannelLinkMd's pattern (mobile/lib/features/channels/message_content/token_pill.dart:13-17, built by _buildPrefixPattern at :107-138) accepts a purely numeric token — genericTokenPattern is [A-Za-z0-9_][A-Za-z0-9_-]* — and its trailing boundary (?=$|[\s,;.!?:)\]}]) is satisfied at the end of the label. So the label #2959 matches and is replaced by a _TokenPill WidgetSpan.
- The resulting
InlineSpan reaches Buzz's linkBuilder at mobile/lib/features/channels/message_content.dart:338-478. visitChildren there only accumulates TextSpan.text, so text comes out empty, and the widget handed back is Text.rich(TextSpan(children: [<WidgetSpan pill>])) — a placeholder nested inside a placeholder. On device the result draws nothing.
Steps 1–3 are certain from the source. Step 4 is the observed outcome; I have not been able to run the Flutter app to confirm exactly where the nested placeholder is lost.
A useful discriminator when confirming: links whose label does not start with # (for example [`0f61f24`](https://github.com/block/buzz/commit/0f61f24ad)) are reported as still visible in the same message.
Note on scope
Reported as a bug, not a proposed patch. The crux is ordering: an authored [label](url) has its label consumed by the custom inline components before it is ever resolved as a link. Link resolution should win over #/@ token detection inside a label. Where in the pipeline that ordering belongs is for whoever owns the mobile Markdown rendering — the reporter is not prescribing a fix, and in particular "reject digits-only tokens" would only hide this instance, not the class.
Related
Describe the bug
On iOS, a Markdown link whose label starts with
#renders as nothing at all — the label, the underline, and the tap target are all gone. Only the surrounding punctuation survives.A message body like
renders on iOS as:
The same message renders correctly on Buzz Desktop (macOS), with three underlined, tappable links.
Steps to reproduce
See [#2959](https://github.com/block/buzz/issues/2959) for details.See for details.Expected behavior
The link renders with its authored label
#2959and opens the URL on tap, as it does on desktop.Actual behavior
Nothing is drawn where the link should be.
Version and platform
main@ f956e6fAnalysis
The link label is being consumed by the
#channelinline component instead of being rendered as link text.MessageContentregisters three custom inline components ahead of the gpt_markdown defaults, one of which is_ChannelLinkMd—mobile/lib/features/channels/message_content.dart:284-296.ATagMd.spanrenders a link's label by recursing:MarkdownComponent.generate(context, linkText, config, false), which resolves components fromconfig.inlineComponents(gpt_markdown 1.1.6,lib/markdown_component.dart:41-44and:858-864). So the custom components run inside link labels._ChannelLinkMd's pattern (mobile/lib/features/channels/message_content/token_pill.dart:13-17, built by_buildPrefixPatternat:107-138) accepts a purely numeric token —genericTokenPatternis[A-Za-z0-9_][A-Za-z0-9_-]*— and its trailing boundary(?=$|[\s,;.!?:)\]}])is satisfied at the end of the label. So the label#2959matches and is replaced by a_TokenPillWidgetSpan.InlineSpanreaches Buzz'slinkBuilderatmobile/lib/features/channels/message_content.dart:338-478.visitChildrenthere only accumulatesTextSpan.text, sotextcomes out empty, and the widget handed back isText.rich(TextSpan(children: [<WidgetSpan pill>]))— a placeholder nested inside a placeholder. On device the result draws nothing.Steps 1–3 are certain from the source. Step 4 is the observed outcome; I have not been able to run the Flutter app to confirm exactly where the nested placeholder is lost.
A useful discriminator when confirming: links whose label does not start with
#(for example[`0f61f24`](https://github.com/block/buzz/commit/0f61f24ad)) are reported as still visible in the same message.Note on scope
Reported as a bug, not a proposed patch. The crux is ordering: an authored
[label](url)has its label consumed by the custom inline components before it is ever resolved as a link. Link resolution should win over#/@token detection inside a label. Where in the pipeline that ordering belongs is for whoever owns the mobile Markdown rendering — the reporter is not prescribing a fix, and in particular "reject digits-only tokens" would only hide this instance, not the class.Related
#4572being turned into a pill, which is the same component firing where it shouldn't.