From 02a7b87b5b9933ffa5bdbec15de0569def18f948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 16 Aug 2026 21:35:41 +0200 Subject: [PATCH] markdown support for talk messages --- lib/api/demo/data/demo_talk.dart | 20 +++ .../talk/chat/get_chat_response.dart | 10 +- .../talk/chat/get_chat_response.g.dart | 2 + lib/utils/url_opener.dart | 8 +- lib/view/pages/talk/data/chat_message.dart | 28 +++-- lib/view/pages/talk/data/talk_markdown.dart | 76 +++++++++++ .../pages/talk/widgets/answer_reference.dart | 14 ++- lib/view/pages/talk/widgets/chat_bubble.dart | 6 +- lib/view/pages/talk/widgets/chat_tile.dart | 15 ++- .../pages/talk/widgets/message_markdown.dart | 118 ++++++++++++++++++ lib/widget_data/widget_data.freezed.dart | 8 +- pubspec.yaml | 3 + test/view/talk/markdown_plain_text_test.dart | 38 ++++++ test/view/talk/message_markdown_test.dart | 52 ++++++++ 14 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 lib/view/pages/talk/data/talk_markdown.dart create mode 100644 lib/view/pages/talk/widgets/message_markdown.dart create mode 100644 test/view/talk/markdown_plain_text_test.dart create mode 100644 test/view/talk/message_markdown_test.dart diff --git a/lib/api/demo/data/demo_talk.dart b/lib/api/demo/data/demo_talk.dart index 0b045bf..eb9d317 100644 --- a/lib/api/demo/data/demo_talk.dart +++ b/lib/api/demo/data/demo_talk.dart @@ -324,6 +324,22 @@ class DemoTalk { message: 'Gute Idee, ich sammle schon mal Vorschläge.', reactions: {'👍': 3}, ), + _msg( + id: base + 4, + token: token, + actor: 'ben', + display: 'Ben', + ago: const Duration(hours: 2), + message: + '## Ablauf Sommerfest\n' + 'Ein paar **Punkte** für Mittwoch:\n\n' + '- Stände aufbauen ab *14 Uhr*\n' + '- [x] Getränke bestellt\n' + '- [ ] Musik organisieren\n\n' + '> Bitte bringt Ideen mit!\n\n' + 'Mehr Infos auf der [SV-Seite](https://marianum-fulda.de).', + markdown: true, + ), ]; } @@ -346,6 +362,7 @@ class DemoTalk { required Duration ago, required String message, Map? reactions, + bool markdown = false, }) => _msg( id: id, token: token, @@ -354,6 +371,7 @@ class DemoTalk { ago: ago, message: message, reactions: reactions, + markdown: markdown, ); static GetChatResponseObject _msg({ @@ -364,6 +382,7 @@ class DemoTalk { required Duration ago, required String message, Map? reactions, + bool markdown = false, }) => GetChatResponseObject( id, token, @@ -380,6 +399,7 @@ class DemoTalk { reactions, null, null, + markdown, ); static GetRoomResponseObject _room({ diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 8cb3b4c..efebe08 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -42,6 +42,11 @@ class GetChatResponseObject { Map? messageParameters; GetChatResponseObject? parent; + /// Whether the server flagged this message to be rendered as Markdown + /// (capability `markdown-messages`). Absent on older servers/messages, in + /// which case it stays `false` and the body renders as plain text. + bool markdown; + GetChatResponseObject( this.id, this.token, @@ -57,8 +62,9 @@ class GetChatResponseObject { this.messageParameters, this.reactions, this.reactionsSelf, - this.parent, - ); + this.parent, [ + this.markdown = false, + ]); factory GetChatResponseObject.fromJson(Map json) => _$GetChatResponseObjectFromJson(json); diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart index c8f0276..9d48637 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart @@ -49,6 +49,7 @@ GetChatResponseObject _$GetChatResponseObjectFromJson( json['parent'] == null ? null : GetChatResponseObject.fromJson(json['parent'] as Map), + json['markdown'] as bool? ?? false, ); Map _$GetChatResponseObjectToJson( @@ -73,6 +74,7 @@ Map _$GetChatResponseObjectToJson( (k, e) => MapEntry(k, e.toJson()), ), 'parent': instance.parent?.toJson(), + 'markdown': instance.markdown, }; const _$GetRoomResponseObjectMessageActorTypeEnumMap = { diff --git a/lib/utils/url_opener.dart b/lib/utils/url_opener.dart index b88f8ab..24164cd 100644 --- a/lib/utils/url_opener.dart +++ b/lib/utils/url_opener.dart @@ -2,9 +2,11 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher_string.dart'; class UrlOpener { - static Future onOpen(LinkableElement link) async { - if (await canLaunchUrlString(link.url)) { - await launchUrlString(link.url); + static Future onOpen(LinkableElement link) => openUrl(link.url); + + static Future openUrl(String url) async { + if (await canLaunchUrlString(url)) { + await launchUrlString(url); } } } diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index a66e490..0f6de2e 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -8,6 +8,7 @@ import '../../../../model/endpoint_data.dart'; import '../../../../utils/emoji_detection.dart'; import '../../../../utils/url_opener.dart'; import '../widgets/highlighted_linkify.dart'; +import '../widgets/message_markdown.dart'; class ChatMessage { String originalMessage; @@ -26,7 +27,11 @@ class ChatMessage { ); } - Widget getWidget({String? highlightQuery, TextStyle? style}) { + Widget getWidget({ + String? highlightQuery, + TextStyle? style, + bool renderMarkdown = false, + }) { final emojiFontSize = standaloneEmojiFontSize(content); final effectiveStyle = emojiFontSize == null ? style @@ -35,12 +40,21 @@ class ChatMessage { height: 1.15, ); - Widget contentWidget = HighlightedLinkify( - text: content, - onOpen: UrlOpener.onOpen, - highlight: highlightQuery, - style: effectiveStyle, - ); + // Markdown replaces the plain linkified text only for regular messages. + // Emoji-only bodies keep their enlarged rendering, and while searching we + // fall back to the linkify path so matches can still be highlighted (the + // search highlight can't be threaded through Markdown formatting). + final hasQuery = highlightQuery?.trim().isNotEmpty ?? false; + final useMarkdown = renderMarkdown && emojiFontSize == null && !hasQuery; + + var contentWidget = useMarkdown + ? MessageMarkdown(data: content, style: effectiveStyle) + : HighlightedLinkify( + text: content, + onOpen: UrlOpener.onOpen, + highlight: highlightQuery, + style: effectiveStyle, + ); // Enlarged emoji glyphs otherwise sit flush against the bubble edges. if (emojiFontSize != null) { diff --git a/lib/view/pages/talk/data/talk_markdown.dart b/lib/view/pages/talk/data/talk_markdown.dart new file mode 100644 index 0000000..094082b --- /dev/null +++ b/lib/view/pages/talk/data/talk_markdown.dart @@ -0,0 +1,76 @@ +import 'package:markdown/markdown.dart' as md; + +/// The Markdown feature set the app renders for Talk messages — CommonMark plus +/// the GFM extensions the official Talk clients enable. +/// +/// It deliberately omits `InlineHtmlSyntax` (raw HTML stays literal), footnotes, +/// alert blocks, color swatches and `:shortcode:` emoji — matching Talk's +/// restrictions. Shared by the renderer ([MessageMarkdown]) and the plain-text +/// stripper below so both agree on what counts as Markdown. +final md.ExtensionSet talkMarkdownExtensionSet = md.ExtensionSet( + [ + const md.FencedCodeBlockSyntax(), + const md.TableSyntax(), + const md.UnorderedListWithCheckboxSyntax(), + const md.OrderedListWithCheckboxSyntax(), + ], + [ + md.StrikethroughSyntax(), + md.AutolinkExtensionSyntax(), + ], +); + +/// Block-level tags after which running text would otherwise glue together; +/// their boundaries become a single space in the flattened preview. +const Set _blockTags = { + 'p', + 'li', + 'blockquote', + 'pre', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'br', + 'hr', + 'tr', + 'th', + 'td', +}; + +/// Flattens a Markdown [source] into a single line of readable plain text — +/// markers (`**`, `#`, `` ` ``, list bullets, link/image syntax …) removed, +/// links reduced to their label and images to their alt text. Used for the +/// one-line previews (reply quote, conversation list) where Talk also strips +/// formatting rather than rendering it. +String markdownToPlainText(String source) { + final nodes = md.Document( + extensionSet: talkMarkdownExtensionSet, + encodeHtml: false, + ).parse(source); + + final buffer = StringBuffer(); + _collect(nodes, buffer); + return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); +} + +void _collect(List nodes, StringBuffer buffer) { + for (final node in nodes) { + if (node is md.Text) { + buffer.write(node.text); + } else if (node is md.Element) { + // Images carry their text in the `alt` attribute, not in child nodes. + if (node.tag == 'img') { + buffer.write(node.attributes['alt'] ?? ''); + continue; + } + final isBlock = _blockTags.contains(node.tag); + if (isBlock) buffer.write(' '); + final children = node.children; + if (children != null) _collect(children, buffer); + if (isBlock) buffer.write(' '); + } + } +} diff --git a/lib/view/pages/talk/widgets/answer_reference.dart b/lib/view/pages/talk/widgets/answer_reference.dart index cded6ca..d3ab652 100644 --- a/lib/view/pages/talk/widgets/answer_reference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../data/chat_bubble_styles.dart'; +import '../data/talk_markdown.dart'; class AnswerReference extends StatelessWidget { final GetChatResponseObject referenceMessage; @@ -43,10 +44,15 @@ class AnswerReference extends StatelessWidget { ), ), Text( - RichObjectStringProcessor.parseToString( - referenceMessage.message, - referenceMessage.messageParameters, - ), + () { + final text = RichObjectStringProcessor.parseToString( + referenceMessage.message, + referenceMessage.messageParameters, + ); + return referenceMessage.markdown + ? markdownToPlainText(text) + : text; + }(), maxLines: 2, style: TextStyle( overflow: TextOverflow.ellipsis, diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 5ff2114..936e800 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -157,7 +157,7 @@ class _ChatBubbleState extends State /// `onTap: null` on the bubble's `GestureDetector` so its /// `TapGestureRecognizer` does not enter the gesture arena — otherwise /// it competes with (and blocks) the per-link `TapGestureRecognizer`s - /// that `HighlightedLinkify` attaches to URL spans. + /// that `HighlightedLinkify` / the Markdown renderer attach to link spans. bool get _hasTapAction { final obj = message.originalData?['object']; if (obj?.type == RichObjectStringObjectType.talkPoll) return true; @@ -280,6 +280,10 @@ class _ChatBubbleState extends State messageWidget: message.getWidget( highlightQuery: widget.highlightQuery, style: _messageTextStyle(context), + renderMarkdown: + widget.bubbleData.markdown && + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment, ), parent: parent, bubbleData: widget.bubbleData, diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index f471b84..36184cc 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -20,6 +20,7 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; +import '../data/talk_markdown.dart'; import '../talk_navigator.dart'; class ChatTile extends StatefulWidget { @@ -62,6 +63,18 @@ class _ChatTileState extends State { void _refreshList() => context.read().refresh(); + /// One-line preview of the last message: rich-object placeholders resolved, + /// newlines flattened and — for Markdown messages — formatting stripped so + /// the list shows readable text rather than raw markers. + String _lastMessagePreview() { + final last = widget.data.lastMessage; + final text = RichObjectStringProcessor.parseToString( + last.message.replaceAll('\n', ' '), + last.messageParameters, + ); + return last.markdown ? markdownToPlainText(text) : text; + } + Future _setCurrentAsRead() async { final token = widget.data.token; final lastId = widget.data.lastMessage.id; @@ -142,7 +155,7 @@ class _ChatTileState extends State { ), subtitle: Text( '${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: ' - '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', + '${_lastMessagePreview()}', overflow: TextOverflow.ellipsis, ), trailing: widget.data.unreadMessages <= 0 diff --git a/lib/view/pages/talk/widgets/message_markdown.dart b/lib/view/pages/talk/widgets/message_markdown.dart new file mode 100644 index 0000000..5e7fa70 --- /dev/null +++ b/lib/view/pages/talk/widgets/message_markdown.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; + +import '../../../../utils/url_opener.dart'; +import '../data/talk_markdown.dart'; + +/// Renders a Talk chat-message body as Markdown, matching the subset the +/// official Nextcloud Talk clients render. +/// +/// Supported (the intentional "Rahmen", kept in sync with Talk's GFM-based +/// renderer): bold, italic, strikethrough, inline code, links (explicit and +/// bare autolinks), headings, ordered/unordered/task lists, fenced code +/// blocks, block quotes, horizontal rules and tables. +/// +/// Deliberately unsupported, mirroring Talk's restrictions: raw HTML is shown +/// literally (never interpreted) and images are not fetched/rendered — only +/// their alt text (or URL) is shown, so a message can never pull a remote +/// resource on display. +class MessageMarkdown extends StatelessWidget { + final String data; + final TextStyle? style; + + const MessageMarkdown({required this.data, this.style, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final base = + style ?? + theme.textTheme.bodyMedium ?? + DefaultTextStyle.of(context).style; + final baseSize = base.fontSize ?? 14; + final muted = (base.color ?? theme.colorScheme.onSurface).withValues( + alpha: 0.55, + ); + final codeBackground = theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.6, + ); + final codeStyle = base.copyWith( + fontFamily: 'monospace', + fontFamilyFallback: const ['monospace'], + fontSize: baseSize * 0.92, + ); + + final styleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith( + p: base, + a: base.copyWith( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + em: base.copyWith(fontStyle: FontStyle.italic), + strong: base.copyWith(fontWeight: FontWeight.bold), + del: base.copyWith(decoration: TextDecoration.lineThrough), + // Talk levels headings down so they stay readable inside a chat bubble; + // we cap them at modest multiples of the body size instead of the theme's + // display sizes. + h1: base.copyWith(fontSize: baseSize * 1.5, fontWeight: FontWeight.bold), + h2: base.copyWith(fontSize: baseSize * 1.4, fontWeight: FontWeight.bold), + h3: base.copyWith(fontSize: baseSize * 1.3, fontWeight: FontWeight.bold), + h4: base.copyWith(fontSize: baseSize * 1.15, fontWeight: FontWeight.bold), + h5: base.copyWith(fontSize: baseSize * 1.05, fontWeight: FontWeight.bold), + h6: base.copyWith(fontWeight: FontWeight.bold), + code: codeStyle.copyWith(backgroundColor: codeBackground), + codeblockPadding: const EdgeInsets.all(8), + codeblockDecoration: BoxDecoration( + color: codeBackground, + borderRadius: BorderRadius.circular(6), + ), + blockquote: base.copyWith(color: muted), + blockquotePadding: const EdgeInsets.only(left: 12, top: 2, bottom: 2), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: muted, width: 4)), + ), + listBullet: base, + checkbox: base, + horizontalRuleDecoration: BoxDecoration( + border: Border(top: BorderSide(color: muted)), + ), + tableHead: base.copyWith(fontWeight: FontWeight.bold), + tableBody: base, + tableBorder: TableBorder.all(color: muted, width: 0.8), + tableCellsPadding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + // Keep multi-block messages compact; the default spacing is too airy for + // a chat bubble. + blockSpacing: baseSize * 0.5, + ); + + return MarkdownBody( + data: data, + styleSheet: styleSheet, + extensionSet: talkMarkdownExtensionSet, + // Talk turns a single newline into a line break (`breaks: true`); + // standard Markdown would collapse it into a space. + softLineBreak: true, + onTapLink: (text, href, title) { + if (href != null && href.isNotEmpty) unawaited(UrlOpener.openUrl(href)); + }, + // No remote fetch: render the alt text (or URL) instead of the image. + imageBuilder: (uri, title, alt) => Text( + (alt != null && alt.isNotEmpty) ? alt : uri.toString(), + style: base, + ), + checkboxBuilder: (checked) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + checked ? Icons.check_box_outlined : Icons.check_box_outline_blank, + size: baseSize + 2, + color: base.color, + ), + ), + ); + } +} diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart index 4d038fa..08f085c 100644 --- a/lib/widget_data/widget_data.freezed.dart +++ b/lib/widget_data/widget_data.freezed.dart @@ -15,7 +15,10 @@ T _$identity(T value) => value; /// @nodoc mixin _$WidgetLesson { - DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room; String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; + DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room;/// On teacher accounts this carries the class label ("7a") instead of the +/// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; +/// [originalTeacher] is null in that case. + String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -226,6 +229,9 @@ class _WidgetLesson implements WidgetLesson { @override final String subjectShort; @override final String? subjectLong; @override final String? room; +/// On teacher accounts this carries the class label ("7a") instead of the +/// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; +/// [originalTeacher] is null in that case. @override final String? teacher; @override final String? originalTeacher; @override final WidgetLessonStatus status; diff --git a/pubspec.yaml b/pubspec.yaml index b1f4bb5..5a8050e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,9 @@ dependencies: app_settings: ^7.0.0 flutter_layout_grid: ^2.0.8 flutter_markdown_plus: ^1.0.12 + # Underlying parser for flutter_markdown_plus; imported directly to build the + # restricted ExtensionSet used for chat-message Markdown. + markdown: ^7.3.1 integration_test: sdk: flutter diff --git a/test/view/talk/markdown_plain_text_test.dart b/test/view/talk/markdown_plain_text_test.dart new file mode 100644 index 0000000..12f678e --- /dev/null +++ b/test/view/talk/markdown_plain_text_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/view/pages/talk/data/talk_markdown.dart'; + +void main() { + group('markdownToPlainText', () { + test('strips emphasis markers', () { + expect( + markdownToPlainText('**bold** and _italic_ and ~~struck~~'), + 'bold and italic and struck', + ); + }); + + test('drops heading markers', () { + expect(markdownToPlainText('# Title'), 'Title'); + }); + + test('reduces links to their label and images to their alt text', () { + expect(markdownToPlainText('see [the docs](https://x.y)'), 'see the docs'); + expect(markdownToPlainText('![a cat](https://x.y/c.png)'), 'a cat'); + }); + + test('flattens lists into one space-separated line', () { + expect(markdownToPlainText('- one\n- two\n- three'), 'one two three'); + }); + + test('unwraps inline code without the backticks', () { + expect(markdownToPlainText('run `flutter test` now'), 'run flutter test now'); + }); + + test('keeps plain text unchanged', () { + expect(markdownToPlainText('just a normal sentence.'), 'just a normal sentence.'); + }); + + test('leaves literal angle brackets intact (no HTML interpretation)', () { + expect(markdownToPlainText('a tag'), 'a tag'); + }); + }); +} diff --git a/test/view/talk/message_markdown_test.dart b/test/view/talk/message_markdown_test.dart new file mode 100644 index 0000000..2178ac8 --- /dev/null +++ b/test/view/talk/message_markdown_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/view/pages/talk/widgets/message_markdown.dart'; + +Future _pump(WidgetTester tester, String data) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MessageMarkdown(data: data)), + ), + ); +} + +void main() { + group('MessageMarkdown – supported subset', () { + testWidgets('renders bold, italic and strikethrough text', (tester) async { + await _pump(tester, '**bold** _italic_ ~~struck~~'); + expect(find.textContaining('bold', findRichText: true), findsWidgets); + expect(find.textContaining('italic', findRichText: true), findsWidgets); + expect(find.textContaining('struck', findRichText: true), findsWidgets); + }); + + testWidgets('renders headings without the # markers', (tester) async { + await _pump(tester, '# Heading'); + expect(find.textContaining('Heading', findRichText: true), findsWidgets); + expect(find.textContaining('#', findRichText: true), findsNothing); + }); + + testWidgets('renders task-list checkboxes', (tester) async { + await _pump(tester, '- [x] done\n- [ ] open'); + expect(find.byIcon(Icons.check_box_outlined), findsOneWidget); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + }); + }); + + group('MessageMarkdown – enforced limits (Rahmen)', () { + testWidgets('shows raw HTML literally instead of interpreting it', ( + tester, + ) async { + await _pump(tester, 'a tag here'); + // The angle-bracket tags must survive as visible text, never as styling. + expect(find.textContaining('', findRichText: true), findsWidgets); + }); + + testWidgets('never fetches images, only shows their alt text', ( + tester, + ) async { + await _pump(tester, '![altText](https://example.com/pic.png)'); + expect(find.byType(Image), findsNothing); + expect(find.textContaining('altText', findRichText: true), findsWidgets); + }); + }); +}