Files
buzz/mobile/lib/shared/custom_emoji/custom_emoji_render.dart
Mohammad Asfour 1a54eec3db fix(mobile): keep token labels visible in authored Markdown links (#7545)
## Summary

On iOS, an authored Markdown link renders as blank when its label is
recognized as a Buzz channel, mention, or custom emoji. For example:

```markdown
[#2959](https://example.com/)
```

The surrounding text remains visible, but the link has no visible label
or tap target.

Buzz's `_ChannelLinkMd`, `_MentionMd`, and `CustomEmojiMd` components
return `WidgetSpan`s. `gpt_markdown` also renders an authored link
through a `WidgetSpan` and reparses its label through the registered
components. Because the Buzz components were allowed to run inside link
labels, the result was a placeholder nested inside another placeholder,
which does not paint on iOS.

This PR adds the same scope restriction used by `gpt_markdown`'s
built-in link component:

```dart
@override
Set<MarkdownScope> get scopes =>
    MarkdownComponent.allScopesExceptLinkLabel;
```

The override is applied to all three widget-based components. Authored
labels such as `[#2959](url)`, `[@Alice](url)`, and `[👋](url)`
therefore remain ordinary link text, while the same tokens outside links
continue rendering as Buzz pills and custom emoji.

The PR also updates the shared test helpers to find `RichText`
subclasses. Since the upgrade to `gpt_markdown` 1.2.1, body paragraphs
can render through `BidiRichText`; `find.byType(RichText)` does not
match that subclass and could allow an absence assertion to pass without
inspecting the message text.

The handling of unknown bare `#tokens` is deliberately unchanged.
Whether those should remain non-interactive pills or become plain text
is a separate product decision.

### Related issue

Fixes #6124.

This supersedes #6135. That PR solves the same issue by maintaining a
custom fork of `gpt_markdown`'s link component; this PR instead uses the
scoped-component API already available in `gpt_markdown` 1.2.1.

### Testing

Focused suites:

```bash
flutter test \
  test/features/channels/message_content_test.dart \
  test/features/channels/message_content_custom_emoji_test.dart
```

Result: 95 tests passed, 0 failed.

Additional checks:

- `just mobile-check` — passed; analyzer reported no issues.
- `just mobile-fmt` — passed; 549 files formatted with 0 changes.
- Six new regression tests cover channel, mention, emoji, plain-link
control, and outside-link behavior.
- Removing the `_ChannelLinkMd` override causes two regression tests to
fail; removing `_MentionMd` or `CustomEmojiMd` causes one test to fail
in each case.
- Three tests in `message_content_test.dart` directly assert that the
nested-placeholder structure is absent. The custom-emoji tests
separately verify literal text and the absence or presence of
`CustomEmojiImage`, depending on whether the shortcode is inside or
outside a link.

The complete local mobile suite reports 15 failures that reproduce
identically on a clean checkout of `main` at `44316ff7`. In [CI for that
same commit](https://github.com/block/buzz/actions/runs/34238736818),
`Clients / Mobile`, `Mobile`, and `Mobile Swift Domain / Mobile Swift`
all passed.

No native-device screenshot is included. The nested-placeholder fix was
verified through the rendered widget structure, but an iOS simulator
build was blocked by the existing `MLImage.framework` arm64-simulator
incompatibility.

Signed-off-by: Mohammad Asfour <masfour700@gmail.com>
Signed-off-by: Samin Yeasar Sohag <sssohag172@gmail.com>
Co-authored-by: Samin Yeasar Sohag <sssohag172@gmail.com>
2026-10-21 10:24:32 -07:00

131 lines
4.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Default rendered height of an inline custom emoji, in logical pixels.
/// Roughly matches a line of body text so emoji sit on the baseline cleanly.
const double kCustomEmojiInlineSize = 20.0;
/// A single custom-emoji image rendered as a square, network-loaded glyph.
///
/// Reused everywhere a `:shortcode:` resolves to an image: inline in message
/// bodies, reaction pills, status, and the picker. Falls back to the literal
/// `:shortcode:` text if the image fails to load, so a broken URL never leaves
/// a blank gap.
class CustomEmojiImage extends StatelessWidget {
final String shortcode;
final String url;
final double size;
const CustomEmojiImage({
super.key,
required this.shortcode,
required this.url,
this.size = kCustomEmojiInlineSize,
});
@override
Widget build(BuildContext context) {
final fallbackStyle = DefaultTextStyle.of(context).style;
return MediaImage(
url: url,
width: size,
height: size,
decodeWidth: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
);
}
}
/// gpt_markdown inline component that replaces `:shortcode:` with an inline
/// [CustomEmojiImage] for *known* shortcodes only. Unknown `:foo:` is left as
/// plain text. Matched case-insensitively; resolved via the lowercase palette.
///
/// Parallel to the `_MentionMd` / `_ChannelLinkMd` components in
/// message_content.dart — add an instance to a `GptMarkdown.inlineComponents`
/// list (before the default components) to enable custom emoji in any markdown
/// surface.
class CustomEmojiMd extends InlineMd {
final Map<String, String> _urlByShortcode;
final double size;
late final RegExp _exp = _buildPattern(_urlByShortcode.keys);
/// Only include shortcodes present in the rendered [content]. gpt_markdown
/// embeds this pattern in a combined regex for every parsed text segment, so
/// unrelated community emoji must not make every message expensive to parse.
CustomEmojiMd(
List<CustomEmoji> palette, {
required String content,
this.size = kCustomEmojiInlineSize,
}) : _urlByShortcode = _referencedUrls(palette, content);
/// Excluded from link labels: this component renders a [WidgetSpan], and a
/// placeholder nested inside the link's own placeholder does not paint on
/// iOS — an authored `[:emoji:](url)` renders as nothing. Link resolution
/// wins over token detection inside a label.
@override
Set<MarkdownScope> get scopes => MarkdownComponent.allScopesExceptLinkLabel;
// Look ahead so adjacent tokens sharing a colon are both considered:
// :unknown:known: must still allow the known token to match.
static final _shortcodeScan = RegExp(
r'(?=:([a-z0-9_-]+):)',
caseSensitive: false,
);
static Map<String, String> _referencedUrls(
List<CustomEmoji> palette,
String content,
) {
final referenced = {
for (final match in _shortcodeScan.allMatches(content))
match.group(1)!.toLowerCase(),
};
if (referenced.isEmpty) return const {};
return {
for (final emoji in palette)
if (referenced.contains(emoji.shortcode)) emoji.shortcode: emoji.url,
};
}
@override
RegExp get exp => _exp;
@override
InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final shortcode = raw.substring(1, raw.length - 1).toLowerCase();
final url = _urlByShortcode[shortcode];
if (url == null) {
return TextSpan(text: text, style: config.style);
}
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CustomEmojiImage(shortcode: shortcode, url: url, size: size),
);
}
/// Build a regex matching `:shortcode:` for any known shortcode, longest
/// first so a longer name isn't shadowed by a shorter prefix. Matches nothing
/// when the palette is empty (a regex that can never match).
static RegExp _buildPattern(Iterable<String> shortcodes) {
final sorted = shortcodes.where((s) => s.trim().isNotEmpty).toSet().toList()
..sort((a, b) => b.length.compareTo(a.length));
if (sorted.isEmpty) {
// Never matches — gpt_markdown skips this component entirely.
return RegExp(r'(?!x)x');
}
final alternatives = sorted.map(RegExp.escape).join('|');
return RegExp(':(?:$alternatives):', caseSensitive: false);
}
}