From 2352eb406a46c5e7b15e1e7defdcdddb1f833c91 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Sat, 9 May 2020 14:00:46 +0000 Subject: [PATCH] add markdown parsing --- lib/src/room.dart | 42 +++++++++++++--- lib/src/utils/markdown.dart | 99 +++++++++++++++++++++++++++++++++++++ pubspec.lock | 14 ++++++ pubspec.yaml | 2 + test/markdown_test.dart | 42 ++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 lib/src/utils/markdown.dart create mode 100644 test/markdown_test.dart diff --git a/lib/src/room.dart b/lib/src/room.dart index b91664d..097d728 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -37,11 +37,13 @@ import 'package:image/image.dart'; import 'package:matrix_file_e2ee/matrix_file_e2ee.dart'; import 'package:mime_type/mime_type.dart'; import 'package:olm/olm.dart' as olm; +import 'package:html_unescape/html_unescape.dart'; import './user.dart'; import 'timeline.dart'; import 'utils/matrix_localizations.dart'; import 'utils/states_map.dart'; +import './utils/markdown.dart'; enum PushRuleState { notify, mentions_only, dont_notify } enum JoinRules { public, knock, invite, private } @@ -478,14 +480,42 @@ class Room { /// Sends a normal text message to this room. Returns the event ID generated /// by the server for this message. - Future sendTextEvent(String message, {String txid, Event inReplyTo}) { - var type = 'm.text'; + Future sendTextEvent(String message, {String txid, Event inReplyTo, bool parseMarkdown = true}) { + final event = { + 'msgtype': 'm.text', + 'body': message, + }; if (message.startsWith('/me ')) { - type = 'm.emote'; - message = message.substring(4); + event['type'] = 'm.emote'; + event['body'] = message.substring(4); } - return sendEvent({'msgtype': type, 'body': message}, - txid: txid, inReplyTo: inReplyTo); + if (parseMarkdown) { + // load the emote packs + final emotePacks = >{}; + final addEmotePack = (String packName, Map content) { + emotePacks[packName] = {}; + content.forEach((key, value) { + if (key is String && value is String && value.startsWith('mxc://')) { + emotePacks[packName][key] = value; + } + }); + }; + final roomEmotes = getState('im.ponies.room_emotes'); + final userEmotes = client.accountData['im.ponies.user_emotes']; + if (roomEmotes != null && roomEmotes.content['short'] is Map) { + addEmotePack('room', roomEmotes.content['short']); + } + if (userEmotes != null && userEmotes.content['short'] is Map) { + addEmotePack('user', userEmotes.content['short']); + } + final html = markdown(event['body'], emotePacks); + // if the decoded html is the same as the body, there is no need in sending a formatted message + if (HtmlUnescape().convert(html) != event['body']) { + event['format'] = 'org.matrix.custom.html'; + event['formatted_body'] = html; + } + } + return sendEvent(event, txid: txid, inReplyTo: inReplyTo); } /// Sends a [file] to this room after uploading it. The [msgType] is optional diff --git a/lib/src/utils/markdown.dart b/lib/src/utils/markdown.dart new file mode 100644 index 0000000..f0577d2 --- /dev/null +++ b/lib/src/utils/markdown.dart @@ -0,0 +1,99 @@ +import 'package:markdown/markdown.dart'; +import 'dart:convert'; + +class LinebreakSyntax extends InlineSyntax { + LinebreakSyntax() : super(r'\n'); + + @override + bool onMatch(InlineParser parser, Match match) { + parser.addNode(Element.empty('br')); + return true; + } +} + +class SpoilerSyntax extends TagSyntax { + Map reasonMap = {}; + SpoilerSyntax() : super( + r'\|\|(?:([^\|]+)\|(?!\|))?', + requiresDelimiterRun: true, + end: r'\|\|', + ); + + @override + bool onMatch(InlineParser parser, Match match) { + if (super.onMatch(parser, match)) { + reasonMap[match.input] = match[1]; + return true; + } + return false; + } + + @override + bool onMatchEnd(InlineParser parser, Match match, TagState state) { + final element = Element('span', state.children); + element.attributes['data-mx-spoiler'] = htmlEscape.convert(reasonMap[match.input] ?? ''); + parser.addNode(element); + return true; + } +} + +class EmoteSyntax extends InlineSyntax { + final Map> emotePacks; + EmoteSyntax(this.emotePacks) : super(r':(?:([-\w]+)~)?([-\w]+):'); + + @override + bool onMatch(InlineParser parser, Match match) { + final pack = match[1] ?? ''; + final emote = ':${match[2]}:'; + String mxc; + if (pack.isEmpty) { + // search all packs + for (final emotePack in emotePacks.values) { + mxc = emotePack[emote]; + if (mxc != null) { + break; + } + } + } else { + mxc = emotePacks[pack] != null ? emotePacks[pack][emote] : null; + } + if (mxc == null) { + // emote not found. Insert the whole thing as plain text + parser.addNode(Text(match[0])); + return true; + } + final element = Element.empty('img'); + element.attributes['src'] = htmlEscape.convert(mxc); + element.attributes['alt'] = htmlEscape.convert(emote); + element.attributes['title'] = htmlEscape.convert(emote); + element.attributes['height'] = '32'; + element.attributes['vertical-align'] = 'middle'; + parser.addNode(element); + return true; + } +} + + +String markdown(String text, [Map> emotePacks]) { + emotePacks ??= >{}; + var ret = markdownToHtml(text, + extensionSet: ExtensionSet.commonMark, + inlineSyntaxes: [StrikethroughSyntax(), LinebreakSyntax(), SpoilerSyntax(), EmoteSyntax(emotePacks)], + ); + + var stripPTags = '

'.allMatches(ret).length <= 1; + if (stripPTags) { + final otherBlockTags = ['table', 'pre', 'ol', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote']; + for (final tag in otherBlockTags) { + // we check for the close tag as the opening one might have attributes + if (ret.contains('')) { + stripPTags = false; + break; + } + } + } + if (stripPTags) { + ret = ret.replaceAll('

', '').replaceAll('

', ''); + } + return ret.trim().replaceAll(RegExp(r'(
)+$'), ''); +} diff --git a/pubspec.lock b/pubspec.lock index bcd7fd7..b8efd9f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -197,6 +197,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.14.0+2" + html_unescape: + dependency: "direct main" + description: + name: html_unescape + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1+3" http: dependency: "direct main" description: @@ -260,6 +267,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.11.3+2" + markdown: + dependency: "direct main" + description: + name: markdown + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 74664bc..ea95e73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: mime_type: ^0.3.0 canonical_json: ^1.0.0 image: ^2.1.4 + markdown: ^2.1.3 + html_unescape: ^1.0.1+3 olm: git: diff --git a/test/markdown_test.dart b/test/markdown_test.dart new file mode 100644 index 0000000..f11af69 --- /dev/null +++ b/test/markdown_test.dart @@ -0,0 +1,42 @@ +import 'package:famedlysdk/src/utils/markdown.dart'; +import 'package:test/test.dart'; + +void main() { + group('markdown', () { + final emotePacks = { + 'room': { + ':fox:': 'mxc://roomfox', + ':bunny:': 'mxc://roombunny', + }, + 'user': { + ':fox:': 'mxc://userfox', + ':bunny:': 'mxc://userbunny', + ':raccoon:': 'mxc://raccoon', + }, + }; + test('simple markdown', () { + expect(markdown('hey *there* how are **you** doing?'), 'hey there how are you doing?'); + expect(markdown('wha ~~strike~~ works!'), 'wha strike works!'); + }); + test('spoilers', () { + expect(markdown('Snape killed ||Dumbledoor||'), 'Snape killed Dumbledoor'); + expect(markdown('Snape killed ||Story|Dumbledoor||'), 'Snape killed Dumbledoor'); + }); + test('multiple paragraphs', () { + expect(markdown('Heya!\n\nBeep'), '

Heya!

\n

Beep

'); + }); + test('Other block elements', () { + expect(markdown('# blah\n\nblubb'), '

blah

\n

blubb

'); + }); + test('linebreaks', () { + expect(markdown('foxies\ncute'), 'foxies
\ncute'); + }); + test('emotes', () { + expect(markdown(':fox:', emotePacks), ':fox:'); + expect(markdown(':user~fox:', emotePacks), ':fox:'); + expect(markdown(':raccoon:', emotePacks), ':raccoon:'); + expect(markdown(':invalid:', emotePacks), ':invalid:'); + expect(markdown(':room~invalid:', emotePacks), ':room~invalid:'); + }); + }); +}