Files
buzz/mobile/test/features/channels/message_content_custom_emoji_test.dart
TolgaCinisli fa1b27bcc8 fix(mobile): style inline code with the app mono face (#6631)
## Summary

Inline code on mobile renders as **bold body text on a faint background
wash** — no monospace face, no chip, and it cannot wrap. #5257 diagnosed
this as a missing `highlightBuilder`.

That is no longer the right fix. `gpt_markdown` 1.2.0 deprecates
`highlightBuilder` (removal in 2.0.0), renders inline code as a real
chip, and adds `InlineCodeStyle` for restyling it. The package author
confirmed this on the issue. So this PR is an upgrade — 1.1.6 → 1.2.1 —
plus one theme declaration, rather than the builder the issue originally
asked for.

**Where the style is declared.** `GptMarkdownThemeData` goes in
`AppTheme._buildTheme`, which both `light()` and `dark()` call. That
reaches all four `GptMarkdown` call sites — `message_content`,
`transcript_item_widget`, `token_pill`, `custom_emoji_render` — so the
style is stated once instead of per widget. A widget-level
`inlineCodeStyle` would have covered channel messages only, leaving the
other three on the package's defaults.

**What is declared.** Face, size, ink, chip fill and outline — not the
face alone. A face name on its own leaves the rest on the package's
defaults, which put inline code at 14.1sp beside a fenced block's 13, on
a neutral `onSurface` tint rather than the app's code surface. In dark
that tint is *lighter* than the surface, while every other code surface
in the app is recessed, so the chip read as a different kind of object.
All of it now comes from one `CodeStyle` declaration that the fenced
block reads from too, so the two cannot be edited apart.

**Three adaptations the upgrade requires.** Each was found by running
the gate, not by reading the changelog:

1. **`imageBuilder` widened** to `(context, url, width, height)`. This
is a hard compile error, and it is **not listed in the package's
migration guide**, which states "nothing here stops code compiling".
Worth reporting upstream.
2. **`autolink` now defaults to `true`.** `normalizeBareLinks()` already
rewrites bare URLs into Markdown links before rendering, so both would
run. `message_content` opts out with `autolink: false` to keep current
behaviour exactly. The migration guide argues for dropping the
pre-processor instead — a better fix, but a behavioural change that
belongs in its own PR.
3. **`gpt_markdown.dart` now re-exports `markdown_config.dart`**, making
two direct imports redundant. `flutter analyze` reports `No issues
found!` on 1.1.6 and flags both on 1.2.1, so these warnings are new, not
pre-existing.

**Deliberately out of scope.** The three non-message call sites now
autolink bare URLs, since only `message_content` has a pre-processor to
collide with. Custom inline components (`_MentionMd`, `CustomEmojiMd`,
`_ChannelLinkMd`) could additionally declare `allScopesExceptLinkLabel`
— 1.2.0 offers it as the fix for a `WidgetSpan` chip going blank inside
a link label on iOS — but current behaviour is unchanged without it, so
that stays a separate change.

### Related issue

Fixes #5257

Duplicate scan: searched `gpt_markdown`, `inline code mobile`,
`highlightBuilder` and `InlineCodeStyle` across both PRs and issues. No
open PR touches inline code styling. #6135 (link labels) and #6166 (text
selection) also touch mobile Markdown but address different defects.

### Testing

Full gate, `just ci` — exit 0:

| Stage | Result |
|---|---|
| Rust (33 suites) | 4768 passed, 0 failed |
| Desktop | 5799 passed, 0 failed |
| Mobile | **2011 passed**, 0 failed |
| `flutter analyze` | `No issues found!` |
| Desktop + web build | ok |

Run on the branch with `main` merged in, so these numbers match what CI
builds.

**New regression test** — `renders inline code in the app code style`.
It resolves the `CodeTextSpan` the package tags inline code with, which
carries both the resolved `TextStyle` and the colours the chip behind it
is painted with, so face, size, ink, fill and outline are all asserted
rather than a widget's presence. It is negative-controlled: reverting
only the theme declaration fails it with

```text
Expected: a numeric value within <0.001> of <13.0>
  Actual: <14.1>
```

and dropping the declaration entirely falls back to
`packages/gpt_markdown/JetBrainsMono` — so the test measures the real
thing, and it would catch a future regression that silently drops the
theme extension.

The test passes `baseStyle: messageBodyTextStyle`, the style the message
surfaces actually use; the widget's own fallback is the smaller
`bodyMedium`, which would move the expected size.

The test finds paragraphs with `find.byWidgetPredicate((widget) =>
widget is RichText)`, not `find.byType(RichText)`: inline code renders
through `BidiRichText`, a `RichText` subclass, and `byType` matches
exact runtime types.

That is a hazard for any test that reads text back out of a paragraph,
and one landed after this branch was cut:
`message_content_custom_emoji_test.dart` arrived with #6996 and its
`code keeps literal emoji while adjacent known tokens render` case reads
a code span through `find.byType(RichText)`. It passes on `main` and
fails on the merge result, which is what CI builds, so it went red only
once CI was authorized. It now uses the same predicate. The two other
`byType(RichText)` call sites — the rest of that file and
`message_author_meta_test.dart` — were re-run and pass: their content
carries no code span, so the exact type still matches. They were left
alone.

### Screenshots

Rendered through the real `MessageContent` widget with the app's own
fonts loaded, at 390pt wide, 3x DPR. Sample text: ``Set `BUZZ_RELAY_URL`
before launch, then run `just mobile-test` to verify.``

| | Before (1.1.6) | After (1.2.1) |
|---|---|---|
| Light |
![before-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-light.png)
|
![after-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-light.png)
|
| Dark |
![before-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-dark.png)
|
![after-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-dark.png)
|

Before: bold Inter on a flat wash, no chip edge, and `just mobile-test`
breaks across the line with the wash simply ending. After: Geist Mono in
a bordered, rounded chip, and the wrapped fragment gets its own chip on
each line.

---------

Signed-off-by: Tolga Cinisli <tolgacinisli@gmail.com>
Co-authored-by: Tolga Cinisli <tolgacinisli@gmail.com>
2026-09-08 08:05:11 -06:00

126 lines
4.3 KiB
Dart

import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Widget _testable(String content, {List<List<String>> tags = const []}) {
return ProviderScope(
overrides: [
customEmojiListProvider.overrideWithValue([
const CustomEmoji(
shortcode: 'wave',
url: 'https://example.com/wave.png',
),
for (var i = 0; i < 2500; i++)
CustomEmoji(
shortcode: 'unused_$i',
url: 'https://example.com/$i.png',
),
]),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: MessageContent(
content: content,
tags: tags,
channelNames: const {'test': 'test-channel'},
),
),
),
);
}
void main() {
testWidgets('message wiring excludes unrelated emoji from the regex', (
tester,
) async {
await tester.pumpWidget(_testable('**hello** :unknown:'));
final markdown = tester.widget<GptMarkdown>(find.byType(GptMarkdown));
final component = markdown.inlineComponents!
.whereType<CustomEmojiMd>()
.single;
expect(component.exp.hasMatch(':unused_2499:'), isFalse);
expect(component.exp.hasMatch(':wave:'), isFalse);
expect(find.byType(CustomEmojiImage), findsNothing);
final text = tester
.widgetList<RichText>(find.byType(RichText))
.map((widget) => widget.text.toPlainText())
.join();
expect(text, contains('hello'));
expect(text, contains(':unknown:'));
expect(text, isNot(contains('**hello**')));
});
testWidgets('referenced event emoji stays available with tag URL priority', (
tester,
) async {
await tester.pumpWidget(
_testable(
':wave:',
tags: const [
['emoji', 'wave', 'https://example.com/event-wave.png'],
],
),
);
final image = tester.widget<CustomEmojiImage>(
find.byType(CustomEmojiImage),
);
expect(image.shortcode, 'wave');
expect(image.url, 'https://example.com/event-wave.png');
final markdown = tester.widget<GptMarkdown>(find.byType(GptMarkdown));
final component = markdown.inlineComponents!
.whereType<CustomEmojiMd>()
.single;
expect(component.exp.hasMatch(':unused_2499:'), isFalse);
expect(component.exp.hasMatch(':wave:'), isTrue);
});
testWidgets('content edits rebuild the scoped matcher', (tester) async {
await tester.pumpWidget(_testable('plain message'));
expect(find.byType(CustomEmojiImage), findsNothing);
await tester.pumpWidget(_testable('edited :wave:'));
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).shortcode,
'wave',
);
await tester.pumpWidget(_testable('edited :unused_2499:'));
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).shortcode,
'unused_2499',
);
final markdown = tester.widget<GptMarkdown>(find.byType(GptMarkdown));
final component = markdown.inlineComponents!
.whereType<CustomEmojiMd>()
.single;
expect(component.exp.hasMatch(':wave:'), isFalse);
await tester.pumpWidget(_testable('edited :unknown:'));
expect(find.byType(CustomEmojiImage), findsNothing);
});
testWidgets('code keeps literal emoji while adjacent known tokens render', (
tester,
) async {
await tester.pumpWidget(_testable('`:wave:` :unknown:wave:'));
expect(find.byType(CustomEmojiImage), findsOneWidget);
// A paragraph holding inline code renders through BidiRichText, a RichText
// subclass, and `find.byType` matches the exact runtime type only.
final text = tester
.widgetList<RichText>(
find.byWidgetPredicate((widget) => widget is RichText),
)
.map((widget) => widget.text.toPlainText())
.join();
expect(text, contains(':wave:'));
expect(text, contains(':unknown'));
});
}