markdown support for talk messages
This commit is contained in:
@@ -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<String, int>? 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<String, int>? reactions,
|
||||
bool markdown = false,
|
||||
}) => GetChatResponseObject(
|
||||
id,
|
||||
token,
|
||||
@@ -380,6 +399,7 @@ class DemoTalk {
|
||||
reactions,
|
||||
null,
|
||||
null,
|
||||
markdown,
|
||||
);
|
||||
|
||||
static GetRoomResponseObject _room({
|
||||
|
||||
@@ -42,6 +42,11 @@ class GetChatResponseObject {
|
||||
Map<String, RichObjectString>? 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<String, dynamic> json) =>
|
||||
_$GetChatResponseObjectFromJson(json);
|
||||
|
||||
@@ -49,6 +49,7 @@ GetChatResponseObject _$GetChatResponseObjectFromJson(
|
||||
json['parent'] == null
|
||||
? null
|
||||
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
|
||||
json['markdown'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||
@@ -73,6 +74,7 @@ Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||
(k, e) => MapEntry(k, e.toJson()),
|
||||
),
|
||||
'parent': instance.parent?.toJson(),
|
||||
'markdown': instance.markdown,
|
||||
};
|
||||
|
||||
const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class UrlOpener {
|
||||
static Future<void> onOpen(LinkableElement link) async {
|
||||
if (await canLaunchUrlString(link.url)) {
|
||||
await launchUrlString(link.url);
|
||||
static Future<void> onOpen(LinkableElement link) => openUrl(link.url);
|
||||
|
||||
static Future<void> openUrl(String url) async {
|
||||
if (await canLaunchUrlString(url)) {
|
||||
await launchUrlString(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +40,16 @@ class ChatMessage {
|
||||
height: 1.15,
|
||||
);
|
||||
|
||||
Widget contentWidget = HighlightedLinkify(
|
||||
// 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,
|
||||
|
||||
@@ -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(
|
||||
<md.BlockSyntax>[
|
||||
const md.FencedCodeBlockSyntax(),
|
||||
const md.TableSyntax(),
|
||||
const md.UnorderedListWithCheckboxSyntax(),
|
||||
const md.OrderedListWithCheckboxSyntax(),
|
||||
],
|
||||
<md.InlineSyntax>[
|
||||
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<String> _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<md.Node> 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(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
() {
|
||||
final text = RichObjectStringProcessor.parseToString(
|
||||
referenceMessage.message,
|
||||
referenceMessage.messageParameters,
|
||||
),
|
||||
);
|
||||
return referenceMessage.markdown
|
||||
? markdownToPlainText(text)
|
||||
: text;
|
||||
}(),
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -157,7 +157,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
/// `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<ChatBubble>
|
||||
messageWidget: message.getWidget(
|
||||
highlightQuery: widget.highlightQuery,
|
||||
style: _messageTextStyle(context),
|
||||
renderMarkdown:
|
||||
widget.bubbleData.markdown &&
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment,
|
||||
),
|
||||
parent: parent,
|
||||
bubbleData: widget.bubbleData,
|
||||
|
||||
@@ -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<ChatTile> {
|
||||
|
||||
void _refreshList() => context.read<ChatListBloc>().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<void> _setCurrentAsRead() async {
|
||||
final token = widget.data.token;
|
||||
final lastId = widget.data.lastMessage.id;
|
||||
@@ -142,7 +155,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
),
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,10 @@ T _$identity<T>(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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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 <b>tag</b>'), 'a <b>tag</b>');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> _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 <b>tag</b> here');
|
||||
// The angle-bracket tags must survive as visible text, never as styling.
|
||||
expect(find.textContaining('<b>', findRichText: true), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('never fetches images, only shows their alt text', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, '');
|
||||
expect(find.byType(Image), findsNothing);
|
||||
expect(find.textContaining('altText', findRichText: true), findsWidgets);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user