From 1809b1f19ba50eaf70b5ec5741bc007acb3771f5 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Sat, 2 Nov 2024 19:28:21 +0100 Subject: [PATCH] feat(neon_rich_text): Render markdown Signed-off-by: provokateurin --- .../neon_rich_text/lib/src/rich_text.dart | 541 +++++++++++++++++- .../packages/neon_rich_text/pubspec.yaml | 2 + .../neon_rich_text/test/rich_text_test.dart | 25 +- .../lib/src/widgets/notification.dart | 8 +- .../talk_app/lib/src/widgets/message.dart | 12 +- .../message_comment_message_with_markdown.png | Bin 0 -> 4596 bytes .../talk_app/test/message_input_test.dart | 2 + .../packages/talk_app/test/message_test.dart | 114 +++- .../talk_app/test/room_page_test.dart | 3 + packages/neon_framework/pubspec.yaml | 1 + 10 files changed, 648 insertions(+), 60 deletions(-) create mode 100644 packages/neon_framework/packages/talk_app/test/goldens/message_comment_message_with_markdown.png diff --git a/packages/neon_framework/packages/neon_rich_text/lib/src/rich_text.dart b/packages/neon_framework/packages/neon_rich_text/lib/src/rich_text.dart index a939e9759ae..741ddf6bfc0 100644 --- a/packages/neon_framework/packages/neon_rich_text/lib/src/rich_text.dart +++ b/packages/neon_framework/packages/neon_rich_text/lib/src/rich_text.dart @@ -1,24 +1,84 @@ import 'package:built_collection/built_collection.dart'; import 'package:built_value/json_object.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:intersperse/intersperse.dart'; +import 'package:logging/logging.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/widgets.dart'; import 'package:neon_rich_text/src/rich_objects/rich_objects.dart'; import 'package:nextcloud/core.dart' as core; +final _log = Logger('RichText'); + /// Renders the [text] as a rich [TextSpan]. TextSpan buildRichTextSpan({ + required Account account, required String text, + required TextStyle textStyle, required BuiltMap> parameters, required BuiltList references, - required TextStyle style, required void Function(String reference) onReferenceClicked, + required bool isMarkdown, bool isPreview = false, }) { + assert( + !isPreview || !isMarkdown, + 'A preview must not be markdown', + ); + if (isPreview) { text = text.replaceAll('\n', ' '); } + if (!isMarkdown) { + return _buildRichObjectSpan( + text: text, + textStyle: textStyle, + parameters: parameters, + isPreview: isPreview, + references: references, + onReferenceClicked: onReferenceClicked, + ); + } + + final document = md.Document( + extensionSet: md.ExtensionSet.gitHubFlavored, + encodeHtml: false, + ); + + final nodes = document.parse(text); + + final spans = _buildMarkdownSpans( + account: account, + nodes: nodes, + textStyle: textStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + ); + + // Here we can just ignore the final newline + return TextSpan( + children: spans.children, + style: textStyle, + ); +} + +TextSpan _buildRichObjectSpan({ + required String text, + required TextStyle textStyle, + required BuiltMap> parameters, + required bool isPreview, + BuiltList? references, + void Function(String reference)? onReferenceClicked, +}) { + assert( + (references == null) == (onReferenceClicked == null), + 'Pass both references and onReferenceClicked or neither of them.', + ); + final unusedParameters = {}; var parts = [text]; @@ -42,15 +102,17 @@ TextSpan buildRichTextSpan({ parts = newParts; } - for (final reference in references) { - final newParts = []; + if (references != null) { + for (final reference in references) { + final newParts = []; - for (final part in parts) { - final p = part.split(reference); - newParts.addAll(p.intersperse(reference)); - } + for (final part in parts) { + final p = part.split(reference); + newParts.addAll(p.intersperse(reference)); + } - parts = newParts; + parts = newParts; + } } final children = []; @@ -61,7 +123,7 @@ TextSpan buildRichTextSpan({ ..add( buildRichObjectParameter( parameter: entry.value, - textStyle: style, + textStyle: textStyle, isPreview: isPreview, ), ) @@ -82,7 +144,7 @@ TextSpan buildRichTextSpan({ parameter: core.RichObjectParameter.fromJson( entry.value.map((key, value) => MapEntry(key, value.toString())).toMap(), ), - textStyle: style, + textStyle: textStyle, isPreview: isPreview, ), ); @@ -90,29 +152,28 @@ TextSpan buildRichTextSpan({ break; } } - for (final reference in references) { - if (reference == part) { - final gestureRecognizer = TapGestureRecognizer()..onTap = () => onReferenceClicked(reference); + if (references != null) { + for (final reference in references) { + if (reference == part) { + final gestureRecognizer = TapGestureRecognizer()..onTap = () => onReferenceClicked!(reference); - children.add( - TextSpan( - text: part, - style: style.copyWith( - decoration: TextDecoration.underline, - decorationThickness: 2, + children.add( + TextSpan( + text: part, + style: textStyle.merge(_linkTextStyle), + recognizer: gestureRecognizer, ), - recognizer: gestureRecognizer, - ), - ); - match = true; - break; + ); + match = true; + break; + } } } if (!match) { children.add( TextSpan( - style: style, + style: textStyle, text: part, ), ); @@ -120,7 +181,433 @@ TextSpan buildRichTextSpan({ } return TextSpan( - style: style, + style: textStyle, + children: children, + ); +} + +const _linkTextStyle = TextStyle( + decoration: TextDecoration.underline, + decorationThickness: 2, +); + +enum _MarkdownListType { + unordered, + ordered, +} + +const _markdownNewlineTags = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'li', + 'p', +]; + +({List children, bool finalNewline}) _buildMarkdownSpans({ + required Account account, + required List nodes, + required TextStyle textStyle, + required BuiltMap> parameters, + required void Function(String reference) onReferenceClicked, + required bool isPreview, + _MarkdownListType? listType, + int listDepth = 0, +}) { + final children = []; + var finalNewline = false; + + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + final span = _buildMarkdownSpan( + account: account, + node: node, + textStyle: textStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + listType: listType, + listIndex: i, + listDepth: listDepth, + ); + + children.add(span.child); + + if (span.finalNewline || node is md.Element && _markdownNewlineTags.contains(node.tag)) { + if (i == nodes.length - 1) { + finalNewline = true; + } else { + children.add(const TextSpan(text: '\n')); + } + } + } + + return ( children: children, + finalNewline: finalNewline, + ); +} + +({InlineSpan child, bool finalNewline}) _buildMarkdownSpan({ + required Account account, + required md.Node node, + required TextStyle textStyle, + required BuiltMap> parameters, + required void Function(String reference) onReferenceClicked, + required bool isPreview, + _MarkdownListType? listType, + int listIndex = 0, + int listDepth = 0, +}) { + if (node is md.Text) { + var text = node.text; + final finalNewline = text.endsWith('\n'); + if (finalNewline) { + text = text.substring(0, text.length - 1); + } + + return ( + child: _buildRichObjectSpan( + text: text, + textStyle: textStyle, + parameters: parameters, + isPreview: isPreview, + // Don't pass references and onReferenceClicked, as we already resolve them separately. + ), + finalNewline: finalNewline, + ); + } + + if (node is md.Element) { + var localTextStyle = textStyle; + var localListType = listType; + var localListDepth = listDepth; + + final childNodes = node.children ?? []; + + switch (node.tag) { + case 'a': + // This introduces a difference between the links resolved by the Reference API and the Markdown parser. + // The web frontend has the exact same issue, where Markdown embedded links are turned into links inline, but + // there is no preview displayed below the message because the Regex does not match the Markdown link. + // A bug report was filed upstream and if it is fixed in the Reference API we will automatically benefit from it as well: + // https://github.com/nextcloud/spreed/issues/13756 + final href = node.attributes['href']; + if (href != null) { + final gestureRecognizer = TapGestureRecognizer()..onTap = () => onReferenceClicked(href); + + return ( + child: TextSpan( + text: (childNodes[0] as md.Text).text, + style: localTextStyle.merge(_linkTextStyle), + recognizer: gestureRecognizer, + ), + finalNewline: false, + ); + } + case 'blockquote': + final spans = _buildMarkdownSpans( + account: account, + nodes: childNodes, + textStyle: localTextStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + listType: localListType, + listDepth: localListDepth, + ); + + return ( + child: WidgetSpan( + child: Container( + margin: const EdgeInsets.only(left: 5), + padding: const EdgeInsets.only(left: 5), + decoration: const BoxDecoration( + border: Border( + left: BorderSide( + color: Colors.grey, + width: 2.5, + ), + ), + ), + child: Text.rich( + TextSpan( + children: spans.children, + style: localTextStyle.copyWith( + color: Colors.grey, + ), + ), + ), + ), + ), + finalNewline: spans.finalNewline, + ); + case 'code': + const backgroundColor = Colors.black; + const foregroundColor = Colors.white; + + localTextStyle = localTextStyle.copyWith( + fontFamily: 'monospace', + backgroundColor: backgroundColor, + color: foregroundColor, + ); + + final spans = _buildMarkdownSpans( + account: account, + nodes: childNodes, + textStyle: localTextStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + listType: localListType, + listDepth: localListDepth, + ); + + // Inline code + if (!spans.finalNewline) { + return ( + child: TextSpan( + children: spans.children, + style: localTextStyle, + ), + finalNewline: spans.finalNewline, + ); + } + + return ( + child: WidgetSpan( + child: Container( + color: backgroundColor, + padding: const EdgeInsets.all(8), + child: Text.rich( + TextSpan( + children: spans.children, + style: localTextStyle, + ), + ), + ), + ), + finalNewline: spans.finalNewline, + ); + case 'del': + localTextStyle = localTextStyle.copyWith( + decoration: TextDecoration.lineThrough, + ); + case 'em': + localTextStyle = localTextStyle.copyWith( + fontStyle: FontStyle.italic, + ); + case 'hr': + return ( + child: const WidgetSpan( + child: Divider(), + ), + finalNewline: false, + ); + case 'h1': + localTextStyle = localTextStyle.copyWith( + fontSize: 32, + fontWeight: FontWeight.bold, + ); + case 'h2': + localTextStyle = localTextStyle.copyWith( + fontSize: 24, + fontWeight: FontWeight.bold, + ); + case 'h3': + localTextStyle = localTextStyle.copyWith( + fontSize: 18.72, + fontWeight: FontWeight.bold, + ); + case 'h4': + localTextStyle = localTextStyle.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ); + case 'h5': + localTextStyle = localTextStyle.copyWith( + fontSize: 13.28, + fontWeight: FontWeight.bold, + ); + case 'h6': + localTextStyle = localTextStyle.copyWith( + fontSize: 10.72, + fontWeight: FontWeight.bold, + ); + case 'img': + return ( + child: WidgetSpan( + child: Tooltip( + message: node.attributes['alt'], + child: NeonUriImage( + uri: Uri.parse(node.attributes['src']!), + account: account, + ), + ), + ), + finalNewline: false, + ); + case 'li': + localListDepth++; + + final spans = _buildMarkdownSpans( + account: account, + nodes: childNodes, + textStyle: localTextStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + listType: localListType, + listDepth: localListDepth, + ); + + int? subListIndex; + for (var i = 0; i < childNodes.length; i++) { + final childNode = childNodes[i]; + if (childNode is md.Element && (childNode.tag == 'ol' || childNode.tag == 'ul')) { + subListIndex = i; + break; + } + } + + return ( + child: TextSpan( + children: [ + TextSpan( + text: ''.padRight(localListDepth), + style: const TextStyle( + fontFamily: 'monospace', + ), + ), + switch (localListType) { + _MarkdownListType.ordered => TextSpan(text: '${listIndex + 1}. '), + _MarkdownListType.unordered => const TextSpan(text: 'ยท '), + _ => throw ArgumentError('List type must be specified when visiting li element.'), + }, + ...spans.children.sublist(0, subListIndex), + if (subListIndex != null) const TextSpan(text: '\n'), + if (subListIndex != null) ...spans.children.sublist(subListIndex), + ], + ), + finalNewline: spans.finalNewline, + ); + case 'ol': + localListType = _MarkdownListType.ordered; + case 'p': + // Do nothing, a final newline will be inserted by the parent _buildMarkdownSpans() call. + break; + case 'pre': + localTextStyle = localTextStyle.copyWith( + fontFamily: 'monospace', + ); + case 'section': + // Do nothing, no special rendering. + case 'strong': + localTextStyle = localTextStyle.copyWith( + fontWeight: FontWeight.bold, + ); + case 'sup': + localTextStyle = localTextStyle.copyWith( + fontFeatures: [ + const FontFeature.superscripts(), + ], + ); + case 'table': + final head = childNodes.firstWhere( + (childNode) => childNode is md.Element && childNode.tag == 'thead', + ) as md.Element; + final columns = (head.children!.first as md.Element).children!.map((childNode) => childNode as md.Element); + + final body = childNodes.firstWhere( + (childNode) => childNode is md.Element && childNode.tag == 'tbody', + ) as md.Element; + final rows = body.children!.map( + (childNode) => (childNode as md.Element).children!.map( + (childNode) => childNode as md.Element, + ), + ); + + return ( + child: WidgetSpan( + child: DataTable( + columns: [ + for (final column in columns) + DataColumn( + headingRowAlignment: + column.attributes['align'] == 'right' ? MainAxisAlignment.end : MainAxisAlignment.start, + label: Text.rich( + _buildMarkdownSpan( + account: account, + node: column.children!.first, + textStyle: textStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + ).child, + ), + ), + ], + rows: [ + for (final row in rows) + DataRow( + cells: [ + for (final cell in row) + DataCell( + Align( + alignment: + cell.attributes['align'] == 'right' ? Alignment.centerRight : Alignment.centerLeft, + child: Text.rich( + _buildMarkdownSpan( + account: account, + node: cell.children!.first, + textStyle: textStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + ).child, + ), + ), + ), + ], + ), + ], + ), + ), + finalNewline: true, + ); + case 'ul': + localListType = _MarkdownListType.unordered; + default: + _log.finer('Unimplemented Node: ${node.tag}'); + } + + final spans = _buildMarkdownSpans( + account: account, + nodes: childNodes, + textStyle: localTextStyle, + parameters: parameters, + onReferenceClicked: onReferenceClicked, + isPreview: isPreview, + listType: localListType, + listDepth: localListDepth, + ); + + return ( + child: TextSpan( + children: spans.children, + style: localTextStyle, + ), + finalNewline: spans.finalNewline, + ); + } + + return ( + child: TextSpan( + text: node.textContent, + ), + finalNewline: false, ); } diff --git a/packages/neon_framework/packages/neon_rich_text/pubspec.yaml b/packages/neon_framework/packages/neon_rich_text/pubspec.yaml index 1b29d51e1de..ec5a927cbe8 100644 --- a/packages/neon_framework/packages/neon_rich_text/pubspec.yaml +++ b/packages/neon_framework/packages/neon_rich_text/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: sdk: flutter flutter_material_design_icons: ^1.1.7296 intersperse: ^2.0.0 + logging: ^1.0.0 + markdown: ^7.0.0 meta: ^1.0.0 neon_framework: git: diff --git a/packages/neon_framework/packages/neon_rich_text/test/rich_text_test.dart b/packages/neon_framework/packages/neon_rich_text/test/rich_text_test.dart index 432133df581..f368bc905d9 100644 --- a/packages/neon_framework/packages/neon_rich_text/test/rich_text_test.dart +++ b/packages/neon_framework/packages/neon_rich_text/test/rich_text_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/testing.dart'; import 'package:neon_framework/widgets.dart'; import 'package:nextcloud/core.dart' as core; @@ -15,19 +16,23 @@ void main() { group('buildRichTextSpan', () { test('Preview without newlines', () { var span = buildRichTextSpan( + account: MockAccount(), text: '123\n456', + isMarkdown: false, parameters: BuiltMap(), references: BuiltList(), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: (_) {}, ).children!.single as TextSpan; expect(span.text, '123\n456'); span = buildRichTextSpan( + account: MockAccount(), text: '123\n456', + isMarkdown: false, parameters: BuiltMap(), references: BuiltList(), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: (_) {}, isPreview: true, ).children!.single as TextSpan; @@ -38,7 +43,9 @@ void main() { for (final type in core.RichObjectParameter_Type.values) { test(type, () { final spans = buildRichTextSpan( + account: MockAccount(), text: 'test', + isMarkdown: false, parameters: BuiltMap({ type.value: BuiltMap({ 'type': JsonObject(type.value), @@ -47,7 +54,7 @@ void main() { }), }), references: BuiltList(), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: (_) {}, ).children!; if (type == core.RichObjectParameter_Type.file) { @@ -64,7 +71,9 @@ void main() { test('Used parameters', () { final spans = buildRichTextSpan( + account: MockAccount(), text: '123 {actor1} 456 {actor2} 789', + isMarkdown: false, parameters: BuiltMap({ 'actor1': BuiltMap({ 'type': JsonObject('user'), @@ -78,7 +87,7 @@ void main() { }), }), references: BuiltList(), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: (_) {}, ).children!; expect(spans, hasLength(5)); @@ -93,10 +102,12 @@ void main() { final callback = MockOnReferenceClickedCallback(); final spans = buildRichTextSpan( + account: MockAccount(), text: 'a 123 b 456 c', + isMarkdown: false, parameters: BuiltMap(), references: BuiltList(['123', '456']), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: callback.call, ).children!; expect(spans, hasLength(5)); @@ -120,7 +131,9 @@ void main() { test('Skip empty parts', () { final spans = buildRichTextSpan( + account: MockAccount(), text: '{actor}', + isMarkdown: false, parameters: BuiltMap({ 'actor': BuiltMap({ 'type': JsonObject(core.RichObjectParameter_Type.user.name), @@ -129,7 +142,7 @@ void main() { }), }), references: BuiltList(), - style: const TextStyle(), + textStyle: const TextStyle(), onReferenceClicked: (_) {}, ).children!; expect(spans, hasLength(1)); diff --git a/packages/neon_framework/packages/notifications_app/lib/src/widgets/notification.dart b/packages/neon_framework/packages/notifications_app/lib/src/widgets/notification.dart index c5b79294bdf..e7cb09fc7c0 100644 --- a/packages/neon_framework/packages/notifications_app/lib/src/widgets/notification.dart +++ b/packages/neon_framework/packages/notifications_app/lib/src/widgets/notification.dart @@ -25,10 +25,12 @@ class NotificationsNotification extends StatelessWidget { final subject = notification.subjectRichParameters!.isNotEmpty ? Text.rich( buildRichTextSpan( + account: NeonProvider.of(context), text: notification.subjectRich!, + isMarkdown: false, parameters: notification.subjectRichParameters!, references: BuiltList(), - style: Theme.of(context).textTheme.bodyLarge!, + textStyle: Theme.of(context).textTheme.bodyLarge!, onReferenceClicked: (_) {}, ), ) @@ -37,10 +39,12 @@ class NotificationsNotification extends StatelessWidget { final message = notification.messageRichParameters!.isNotEmpty ? Text.rich( buildRichTextSpan( + account: NeonProvider.of(context), text: notification.messageRich!, + isMarkdown: false, parameters: notification.messageRichParameters!, references: BuiltList(), - style: Theme.of(context).textTheme.bodyMedium!, + textStyle: Theme.of(context).textTheme.bodyMedium!, onReferenceClicked: (_) {}, ), overflow: TextOverflow.ellipsis, diff --git a/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart b/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart index 9b937ba5fbf..7f10def3f9b 100644 --- a/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart +++ b/packages/neon_framework/packages/talk_app/lib/src/widgets/message.dart @@ -83,11 +83,13 @@ class TalkMessagePreview extends StatelessWidget { ), ), buildRichTextSpan( + account: NeonProvider.of(context), text: chatMessage.message, + isMarkdown: false, parameters: chatMessage.messageParameters, references: BuiltList(), isPreview: true, - style: Theme.of(context).textTheme.bodyMedium!, + textStyle: Theme.of(context).textTheme.bodyMedium!, onReferenceClicked: (url) async => launchUrl(NeonProvider.of(context), url), ), ], @@ -174,10 +176,12 @@ class TalkSystemMessage extends StatelessWidget { child: Center( child: RichText( text: buildRichTextSpan( + account: NeonProvider.of(context), text: chatMessage.message, + isMarkdown: chatMessage.markdown, parameters: chatMessage.messageParameters, references: BuiltList(), - style: Theme.of(context).textTheme.labelSmall!, + textStyle: Theme.of(context).textTheme.labelSmall!, onReferenceClicked: (url) async => launchUrl(NeonProvider.of(context), url), ), ), @@ -377,11 +381,13 @@ class _TalkCommentMessageState extends State { Widget text = Text.rich( buildRichTextSpan( + account: NeonProvider.of(context), text: widget.chatMessage.message, + isMarkdown: widget.chatMessage.markdown && !widget.isParent, parameters: widget.chatMessage.messageParameters, isPreview: widget.isParent, references: references.keys.toBuiltList(), - style: textTheme.bodyLarge!.copyWith( + textStyle: textTheme.bodyLarge!.copyWith( color: widget.isParent || widget.chatMessage.messageType == spreed.MessageType.commentDeleted ? labelColor : null, diff --git a/packages/neon_framework/packages/talk_app/test/goldens/message_comment_message_with_markdown.png b/packages/neon_framework/packages/talk_app/test/goldens/message_comment_message_with_markdown.png new file mode 100644 index 0000000000000000000000000000000000000000..81fca0924de44ddc48da74f7f2f7261852669982 GIT binary patch literal 4596 zcmeHKYfze38a~E!wMnB%yVY^yf@wBmvr(D!iyDG}y6#$crqoR6c8gKA_9BN{@DGyGdu47@ccRR zy>sThJkRsK=X_g6U!NNCdG*0}`+`pYG3s`5^AD{tABG#r743;pQA2x~mZ@>n z6u0k4;^WV2crA_^Kb`rD#vr|#p=hgaQrH}dbDG6mEX?F@zbnMUZP|7x5&-t+zzu-6 zcI^gWz;izU0f)E2cw04Gsp`Tq{>q?eHssptykS0~%(^YXT`ABG+~ghplv>_29C7KA zcKJzG`mX^PH~Ab);>vK?UJD;FWy@2it3_9esQsuEK8bkBJkY`mwVnfDR??{lqGE=& z=Q_{3Q9e!jQ6A(Y^u#h#A>j8Mw0^hT@cWvx_cu#Et3E|wtcQF&fQ;bVgc`{Tj?6+mmvRJ7D*;)`bShcueccZwX6RpXORlDs!=By&m}z z`t}cMk~%k77OUiL!wkVxmc^m?}DM=6k1n?*)Lhl8%4YUd;<9Ouj(~3#V$x&iB+V%}mX8QxSFQ`EjJJ zyWqVriM3|5#kIjf_<{)AL#^CZO-?%qKo^mAEK)7$n*58E8;nV-;y70ZMXUKEBP*M+ zVpImxStnY3B*MCq5=K@YT}@!+GZ~lZ5N_u(&uyjmMnaP z3C(i?M{iJ5nq97U2f*VoUCBb>rw_dXjspjZ?zE zhKax-NVrp9&oB>%YnQW&7G9Gw?T_STb$6b<1lS)f-6?q_EoAB&@=7creX90R5mz=k zCWZX==E=ZBcZ(ENv@tuhSpEtCIXSAgBAwP;+yIpnl9YcQs{JGVmu@RUak|lid#AwT z<*hR`zW}H`f_=ESW-pXlZE$|?-5k?c)lAD?B_$p;qdy9*LlbROosMX?m6v0;NOKx{ zBCPQvg&3VgKxcZ)4-r_Ki&QiWx0OiOD@O@kt`e1{PknybnJ2HWIv5boO|$7afNYmqz%UG?0lc? zkMG_vgxfEN0FY6*)k`T7i^ljT*h{PKD#f4%;o#eqXW>u~UzQzFaZr4cq0YB7$Oqy4 zvwk-2q6}bj3tm5D@2l%KuwR0Vs_3p;M9ZxFW>Jps{NK-oLhy2$Rbi1{!4$>q{QJ{G zJd5K%G$f|RChz<(V)LH-h8{5)e=1$A>`y`rKZ{Kg9C3Zld#Ug3skJeF_V5ByZ#8WA zhNM>Y1yLLi)QR&yRMuZj;FT4gp$^J#LyAwT8)FS-NVodN!IM@D(tHvyNJ0TTz+RuyDi3B$+#2d zZ)2&bm-8NObYu!IkgO0cE0>NhwY2oftJ4L{47#@Anw9c3q8DzB$t5CgPXz~`OVi#w zEWEmaPUB6iBrf8bt9Tgyl;D%ZE9+hfaQ+)o#Cs}uiEmbu$UOp0j|@Sl*N~Q)W4wa@ zoR34{)r}bFYwcm9`+R1sR|86Rsz)+;RKGboDhm+o*18Eb}#G_*GW_A=am7n6T4A;)7| zZ_I7Gns)+4{*N9-JIL=K4@deoKgD-cx1+ip)otg`?@q`4|99Lb`j chatMessage.actorDisplayName).thenReturn('test'); when(() => chatMessage.message).thenReturn('message'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); replyTo.add(chatMessage); await tester.pumpAndSettle(); @@ -315,6 +316,7 @@ void main() { when(() => chatMessage.actorDisplayName).thenReturn('test'); when(() => chatMessage.message).thenReturn('message'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); editing.add(chatMessage); await tester.pumpAndSettle(); diff --git a/packages/neon_framework/packages/talk_app/test/message_test.dart b/packages/neon_framework/packages/talk_app/test/message_test.dart index e2eed60d849..d3745151b2e 100644 --- a/packages/neon_framework/packages/talk_app/test/message_test.dart +++ b/packages/neon_framework/packages/talk_app/test/message_test.dart @@ -79,6 +79,7 @@ core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data buildCapabilities(core.S ); void main() { + late Account account; late spreed.Room room; late ReferencesBloc referencesBloc; late CapabilitiesBloc capabilitiesBloc; @@ -94,6 +95,8 @@ void main() { }); setUp(() { + account = MockAccount(); + room = MockRoom(); when(() => room.readOnly).thenReturn(0); when(() => room.permissions).thenReturn(spreed.ParticipantPermission.canSendMessageAndShareAndReact.binary); @@ -181,6 +184,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'test', roomType: spreed.RoomType.group, @@ -201,6 +207,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'abc', roomType: spreed.RoomType.group, @@ -220,6 +229,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'test', roomType: spreed.RoomType.oneToOne, @@ -239,6 +251,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'abc', roomType: spreed.RoomType.oneToOne, @@ -258,6 +273,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'abc', roomType: spreed.RoomType.group, @@ -277,6 +295,9 @@ void main() { await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessagePreview( actorId: 'abc', roomType: spreed.RoomType.oneToOne, @@ -295,9 +316,13 @@ void main() { when(() => chatMessage.systemMessage).thenReturn(''); when(() => chatMessage.message).thenReturn(''); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkMessage( room: room, chatMessage: chatMessage, @@ -309,8 +334,6 @@ void main() { }); testWidgets('Comment', (tester) async { - final account = MockAccount(); - final chatMessage = MockChatMessage(); when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => chatMessage.timestamp).thenReturn(0); @@ -321,6 +344,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap()); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -350,9 +374,13 @@ void main() { when(() => chatMessage.systemMessage).thenReturn(''); when(() => chatMessage.message).thenReturn('test'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkSystemMessage( chatMessage: chatMessage, previousChatMessage: null, @@ -372,9 +400,13 @@ void main() { when(() => chatMessage.systemMessage).thenReturn(''); when(() => chatMessage.message).thenReturn('test'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( + providers: [ + Provider.value(value: account), + ], child: TalkSystemMessage( chatMessage: chatMessage, previousChatMessage: previousChatMessage, @@ -391,8 +423,6 @@ void main() { }); testWidgets('TalkParentMessage', (tester) async { - final account = MockAccount(); - final chatMessage = MockChatMessage(); when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => chatMessage.timestamp).thenReturn(0); @@ -402,6 +432,7 @@ void main() { when(() => chatMessage.message).thenReturn('abc'); when(() => chatMessage.reactions).thenReturn(BuiltMap()); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( @@ -440,6 +471,7 @@ void main() { when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.id).thenReturn(0); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -474,8 +506,6 @@ void main() { }); testWidgets('Other', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -492,6 +522,7 @@ void main() { when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.id).thenReturn(0); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -526,8 +557,6 @@ void main() { }); testWidgets('Deleted', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -543,6 +572,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap()); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( @@ -569,8 +599,6 @@ void main() { }); testWidgets('As parent', (tester) async { - final account = MockAccount(); - final chatMessage = MockChatMessage(); when(() => chatMessage.timestamp).thenReturn(0); when(() => chatMessage.actorId).thenReturn('test'); @@ -578,6 +606,7 @@ void main() { when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => chatMessage.message).thenReturn('abc'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.markdown).thenReturn(false); await tester.pumpWidgetWithAccessibility( wrapWidget( @@ -606,8 +635,6 @@ void main() { }); testWidgets('With parent', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -620,6 +647,7 @@ void main() { when(() => parentChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => parentChatMessage.message).thenReturn('abc'); when(() => parentChatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => parentChatMessage.markdown).thenReturn(false); final chatMessage = MockChatMessageWithParent(); when(() => chatMessage.timestamp).thenReturn(0); @@ -632,6 +660,7 @@ void main() { when(() => chatMessage.parent).thenReturn((chatMessage: parentChatMessage, deletedChatMessage: null)); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -689,8 +718,6 @@ void main() { ), ); - final account = MockAccount(); - final chatMessage = MockChatMessageWithParent(); when(() => chatMessage.id).thenReturn(0); when(() => chatMessage.timestamp).thenReturn(0); @@ -702,6 +729,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap()); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -748,10 +776,53 @@ void main() { ); }); + testWidgets('With markdown', (tester) async { + final chatMessage = MockChatMessageWithParent(); + when(() => chatMessage.id).thenReturn(0); + when(() => chatMessage.timestamp).thenReturn(0); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorType).thenReturn(spreed.ActorType.users); + when(() => chatMessage.actorDisplayName).thenReturn('test'); + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => chatMessage.message).thenReturn(''' +# abc + +def +'''); + when(() => chatMessage.reactions).thenReturn(BuiltMap()); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(true); + + final roomBloc = MockRoomBloc(); + when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + NeonProvider.value(value: capabilitiesBloc), + ], + child: TalkCommentMessage( + room: room, + chatMessage: chatMessage, + lastCommonRead: null, + ), + ), + ); + + await tester.pumpAndSettle(); + + await expectLater( + find.byType(TalkCommentMessage).first, + matchesGoldenFile('goldens/message_comment_message_with_markdown.png'), + ); + }); + group('Separate messages', () { testWidgets('Actor', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -767,6 +838,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap({'๐Ÿ˜€': 1, '๐Ÿ˜Š': 23})); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -800,8 +872,6 @@ void main() { }); testWidgets('Time', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -817,6 +887,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap({'๐Ÿ˜€': 1, '๐Ÿ˜Š': 23})); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -849,8 +920,6 @@ void main() { }); testWidgets('System message', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.system); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -866,6 +935,7 @@ void main() { when(() => chatMessage.reactions).thenReturn(BuiltMap({'๐Ÿ˜€': 1, '๐Ÿ˜Š': 23})); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -899,8 +969,6 @@ void main() { }); testWidgets('Edited', (tester) async { - final account = MockAccount(); - final previousChatMessage = MockChatMessage(); when(() => previousChatMessage.messageType).thenReturn(spreed.MessageType.comment); when(() => previousChatMessage.timestamp).thenReturn(0); @@ -918,6 +986,7 @@ void main() { when(() => chatMessage.lastEditTimestamp).thenReturn(0); when(() => chatMessage.lastEditActorDisplayName).thenReturn('test'); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); final roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); @@ -972,6 +1041,7 @@ void main() { when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage.id).thenReturn(0); when(() => chatMessage.isReplyable).thenReturn(true); + when(() => chatMessage.markdown).thenReturn(false); roomBloc = MockRoomBloc(); when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); diff --git a/packages/neon_framework/packages/talk_app/test/room_page_test.dart b/packages/neon_framework/packages/talk_app/test/room_page_test.dart index ba124538f0f..e5f1533076d 100644 --- a/packages/neon_framework/packages/talk_app/test/room_page_test.dart +++ b/packages/neon_framework/packages/talk_app/test/room_page_test.dart @@ -121,6 +121,7 @@ void main() { when(() => chatMessage1.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage1.systemMessage).thenReturn(''); when(() => chatMessage1.isReplyable).thenReturn(true); + when(() => chatMessage1.markdown).thenReturn(false); final chatMessage2 = MockChatMessageWithParent(); when(() => chatMessage2.id).thenReturn(2); @@ -134,6 +135,7 @@ void main() { when(() => chatMessage2.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage2.systemMessage).thenReturn(''); when(() => chatMessage2.isReplyable).thenReturn(true); + when(() => chatMessage2.markdown).thenReturn(false); final chatMessage3 = MockChatMessageWithParent(); when(() => chatMessage3.id).thenReturn(3); @@ -147,6 +149,7 @@ void main() { when(() => chatMessage3.messageParameters).thenReturn(BuiltMap()); when(() => chatMessage3.systemMessage).thenReturn(''); when(() => chatMessage3.isReplyable).thenReturn(true); + when(() => chatMessage3.markdown).thenReturn(false); when(() => bloc.messages).thenAnswer( (_) => BehaviorSubject.seeded( diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 6ca6b24a405..9787c910fb3 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: intersperse: ^2.0.0 intl: ^0.19.0 logging: ^1.0.0 + markdown: ^7.0.0 meta: ^1.0.0 neon_http_client: git: