add markdown parsing
This commit is contained in:
parent
05c99d2b04
commit
2352eb406a
|
@ -37,11 +37,13 @@ import 'package:image/image.dart';
|
||||||
import 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
|
import 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
|
||||||
import 'package:mime_type/mime_type.dart';
|
import 'package:mime_type/mime_type.dart';
|
||||||
import 'package:olm/olm.dart' as olm;
|
import 'package:olm/olm.dart' as olm;
|
||||||
|
import 'package:html_unescape/html_unescape.dart';
|
||||||
|
|
||||||
import './user.dart';
|
import './user.dart';
|
||||||
import 'timeline.dart';
|
import 'timeline.dart';
|
||||||
import 'utils/matrix_localizations.dart';
|
import 'utils/matrix_localizations.dart';
|
||||||
import 'utils/states_map.dart';
|
import 'utils/states_map.dart';
|
||||||
|
import './utils/markdown.dart';
|
||||||
|
|
||||||
enum PushRuleState { notify, mentions_only, dont_notify }
|
enum PushRuleState { notify, mentions_only, dont_notify }
|
||||||
enum JoinRules { public, knock, invite, private }
|
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
|
/// Sends a normal text message to this room. Returns the event ID generated
|
||||||
/// by the server for this message.
|
/// by the server for this message.
|
||||||
Future<String> sendTextEvent(String message, {String txid, Event inReplyTo}) {
|
Future<String> sendTextEvent(String message, {String txid, Event inReplyTo, bool parseMarkdown = true}) {
|
||||||
var type = 'm.text';
|
final event = <String, dynamic>{
|
||||||
|
'msgtype': 'm.text',
|
||||||
|
'body': message,
|
||||||
|
};
|
||||||
if (message.startsWith('/me ')) {
|
if (message.startsWith('/me ')) {
|
||||||
type = 'm.emote';
|
event['type'] = 'm.emote';
|
||||||
message = message.substring(4);
|
event['body'] = message.substring(4);
|
||||||
}
|
}
|
||||||
return sendEvent({'msgtype': type, 'body': message},
|
if (parseMarkdown) {
|
||||||
txid: txid, inReplyTo: inReplyTo);
|
// load the emote packs
|
||||||
|
final emotePacks = <String, Map<String, String>>{};
|
||||||
|
final addEmotePack = (String packName, Map<String, dynamic> content) {
|
||||||
|
emotePacks[packName] = <String, String>{};
|
||||||
|
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
|
/// Sends a [file] to this room after uploading it. The [msgType] is optional
|
||||||
|
|
99
lib/src/utils/markdown.dart
Normal file
99
lib/src/utils/markdown.dart
Normal file
|
@ -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<String, String> reasonMap = <String, String>{};
|
||||||
|
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<String, Map<String, String>> 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<String, Map<String, String>> emotePacks]) {
|
||||||
|
emotePacks ??= <String, Map<String, String>>{};
|
||||||
|
var ret = markdownToHtml(text,
|
||||||
|
extensionSet: ExtensionSet.commonMark,
|
||||||
|
inlineSyntaxes: [StrikethroughSyntax(), LinebreakSyntax(), SpoilerSyntax(), EmoteSyntax(emotePacks)],
|
||||||
|
);
|
||||||
|
|
||||||
|
var stripPTags = '<p>'.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('</${tag}>')) {
|
||||||
|
stripPTags = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stripPTags) {
|
||||||
|
ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
|
||||||
|
}
|
||||||
|
return ret.trim().replaceAll(RegExp(r'(<br />)+$'), '');
|
||||||
|
}
|
14
pubspec.lock
14
pubspec.lock
|
@ -197,6 +197,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.0+2"
|
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:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -260,6 +267,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.3+2"
|
version: "0.11.3+2"
|
||||||
|
markdown:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: markdown
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -12,6 +12,8 @@ dependencies:
|
||||||
mime_type: ^0.3.0
|
mime_type: ^0.3.0
|
||||||
canonical_json: ^1.0.0
|
canonical_json: ^1.0.0
|
||||||
image: ^2.1.4
|
image: ^2.1.4
|
||||||
|
markdown: ^2.1.3
|
||||||
|
html_unescape: ^1.0.1+3
|
||||||
|
|
||||||
olm:
|
olm:
|
||||||
git:
|
git:
|
||||||
|
|
42
test/markdown_test.dart
Normal file
42
test/markdown_test.dart
Normal file
|
@ -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 <em>there</em> how are <strong>you</strong> doing?');
|
||||||
|
expect(markdown('wha ~~strike~~ works!'), 'wha <del>strike</del> works!');
|
||||||
|
});
|
||||||
|
test('spoilers', () {
|
||||||
|
expect(markdown('Snape killed ||Dumbledoor||'), 'Snape killed <span data-mx-spoiler="">Dumbledoor</span>');
|
||||||
|
expect(markdown('Snape killed ||Story|Dumbledoor||'), 'Snape killed <span data-mx-spoiler="Story">Dumbledoor</span>');
|
||||||
|
});
|
||||||
|
test('multiple paragraphs', () {
|
||||||
|
expect(markdown('Heya!\n\nBeep'), '<p>Heya!</p>\n<p>Beep</p>');
|
||||||
|
});
|
||||||
|
test('Other block elements', () {
|
||||||
|
expect(markdown('# blah\n\nblubb'), '<h1>blah</h1>\n<p>blubb</p>');
|
||||||
|
});
|
||||||
|
test('linebreaks', () {
|
||||||
|
expect(markdown('foxies\ncute'), 'foxies<br />\ncute');
|
||||||
|
});
|
||||||
|
test('emotes', () {
|
||||||
|
expect(markdown(':fox:', emotePacks), '<img src="mxc://roomfox" alt=":fox:" title=":fox:" height="32" vertical-align="middle" />');
|
||||||
|
expect(markdown(':user~fox:', emotePacks), '<img src="mxc://userfox" alt=":fox:" title=":fox:" height="32" vertical-align="middle" />');
|
||||||
|
expect(markdown(':raccoon:', emotePacks), '<img src="mxc://raccoon" alt=":raccoon:" title=":raccoon:" height="32" vertical-align="middle" />');
|
||||||
|
expect(markdown(':invalid:', emotePacks), ':invalid:');
|
||||||
|
expect(markdown(':room~invalid:', emotePacks), ':room~invalid:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue