From ee287a09b9008713c0823d274c0fed8f20ed5dcb Mon Sep 17 00:00:00 2001 From: Sorunome Date: Sun, 18 Jul 2021 13:22:49 +0200 Subject: [PATCH] feat: Add support for nicer mentions This PR adds support for nicer mentions in markdown: You can now fetch the mention string of a user with `user.mention` which is human-friendly (typically contains the display name), which will get properly pillified upon passing through the markdown parser. --- lib/src/room.dart | 6 ++++- lib/src/user.dart | 46 ++++++++++++++++++++++++++++++++++ lib/src/utils/markdown.dart | 50 ++++++++++++++++++++++++++++--------- test/markdown_test.dart | 39 +++++++++++++++++++++++++---- test/user_test.dart | 27 +++++++++++++++++++- 5 files changed, 149 insertions(+), 19 deletions(-) diff --git a/lib/src/room.dart b/lib/src/room.dart index 68c73bb2..297f0d8c 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -647,7 +647,11 @@ class Room { 'body': message, }; if (parseMarkdown) { - final html = markdown(event['body'], emotePacks ?? this.emotePacks); + final mentionMap = { + for (final user in getParticipants()) user.mention: user.id + }; + final html = markdown(event['body'], + emotePacks: emotePacks ?? this.emotePacks, mentionMap: mentionMap); // if the decoded html is the same as the body, there is no need in sending a formatted message if (HtmlUnescape().convert(html.replaceAll(RegExp(r'
\n?'), '\n')) != event['body']) { diff --git a/lib/src/user.dart b/lib/src/user.dart index 36b6204f..b3c836d9 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -168,4 +168,50 @@ class User extends Event { other.id == id && other.room == room && other.membership == membership); + + /// Get the mention text to use in a plain text body to mention this specific user + /// in this specific room + String get mention { + // if the displayname has [ or ] or : we can't build our more fancy stuff, so fall back to the id + // [] is used for the delimitors + // If we allowed : we could get collissions with the mxid fallbacks + if ((displayName?.isEmpty ?? true) || + {'[', ']', ':'}.any((c) => displayName.contains(c))) { + return id; + } + + var identifier = '@'; + // if we have non-word characters we need to surround with [] + if (!RegExp(r'^\w+$').hasMatch(displayName)) { + identifier += '[$displayName]'; + } else { + identifier += displayName; + } + + // get all the users with the same display name + final allUsersWithSameDisplayname = room.getParticipants(); + allUsersWithSameDisplayname.removeWhere((user) => + user.id == id || + (user.displayName?.isEmpty ?? true) || + user.displayName != displayName); + if (allUsersWithSameDisplayname.isEmpty) { + return identifier; + } + // ok, we have multiple users with the same display name....time to calculate a hash + final hashes = allUsersWithSameDisplayname.map((u) => _hash(u.id)); + final ourHash = _hash(id); + // hash collission...just return our own mxid again + if (hashes.contains(ourHash)) { + return id; + } + return '$identifier#$ourHash'; + } +} + +String _hash(String s) { + var number = 0; + for (var i = 0; i < s.length; i++) { + number += s.codeUnitAt(i); + } + return (number % 10000).toString(); } diff --git a/lib/src/utils/markdown.dart b/lib/src/utils/markdown.dart index 9084a0d0..abf8ca52 100644 --- a/lib/src/utils/markdown.dart +++ b/lib/src/utils/markdown.dart @@ -98,8 +98,9 @@ class InlineLatexSyntax extends TagSyntax { @override bool onMatch(InlineParser parser, Match match) { - final element = Element('span', [Element.text('code', match[1])]); - element.attributes['data-mx-maths'] = match[1]; + final element = + Element('span', [Element.text('code', htmlEscape.convert(match[1]))]); + element.attributes['data-mx-maths'] = htmlAttrEscape.convert(match[1]); parser.addNode(element); return true; } @@ -135,18 +136,19 @@ class BlockLatexSyntax extends BlockSyntax { Node parse(BlockParser parser) { final childLines = parseChildLines(parser); // we use .substring(2) as childLines will *always* contain the first two '$$' - final latex = - htmlEscape.convert(childLines.join('\n').trim().substring(2).trim()); + final latex = childLines.join('\n').trim().substring(2).trim(); final element = Element('div', [ - Element('pre', [Element.text('code', latex)]) + Element('pre', [Element.text('code', htmlEscape.convert(latex))]) ]); - element.attributes['data-mx-maths'] = latex; + element.attributes['data-mx-maths'] = htmlAttrEscape.convert(latex); return element; } } class PillSyntax extends InlineSyntax { - PillSyntax() : super(r'([@#!][^\s:]*:[^\s]+\.\w+)'); + PillSyntax() + : super( + r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)'); @override bool onMatch(InlineParser parser, Match match) { @@ -156,15 +158,38 @@ class PillSyntax extends InlineSyntax { return true; } final identifier = match[1]; - final element = Element.text('a', identifier); - element.attributes['href'] = 'https://matrix.to/#/$identifier'; + final element = Element.text('a', htmlEscape.convert(identifier)); + element.attributes['href'] = + htmlAttrEscape.convert('https://matrix.to/#/$identifier'); parser.addNode(element); return true; } } -String markdown(String text, [Map> emotePacks]) { - emotePacks ??= >{}; +class MentionSyntax extends InlineSyntax { + final Map mentionMap; + MentionSyntax(this.mentionMap) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)'); + + @override + bool onMatch(InlineParser parser, Match match) { + if ((match.start > 0 && + !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) || + !mentionMap.containsKey(match[1])) { + parser.addNode(Text(match[0])); + return true; + } + final identifier = mentionMap[match[1]]; + final element = Element.text('a', htmlEscape.convert(match[1])); + element.attributes['href'] = + htmlAttrEscape.convert('https://matrix.to/#/$identifier'); + parser.addNode(element); + return true; + } +} + +String markdown(String text, + {Map> emotePacks, + Map mentionMap}) { var ret = markdownToHtml( text, extensionSet: ExtensionSet.commonMark, @@ -175,8 +200,9 @@ String markdown(String text, [Map> emotePacks]) { StrikethroughSyntax(), LinebreakSyntax(), SpoilerSyntax(), - EmoteSyntax(emotePacks), + EmoteSyntax(emotePacks ?? >{}), PillSyntax(), + MentionSyntax(mentionMap ?? {}), InlineLatexSyntax(), ], ); diff --git a/test/markdown_test.dart b/test/markdown_test.dart index 5ff73a96..35a97607 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -32,6 +32,13 @@ void main() { ':raccoon:': 'mxc://raccoon', }, }; + final mentionMap = { + '@Bob': '@bob:example.org', + '@[Bob Ross]': '@bobross:example.org', + '@Fox#123': '@fox:example.org', + '@[Fast Fox]#123': '@fastfox:example.org', + '@[">]': '@blah:example.org', + }; test('simple markdown', () { expect(markdown('hey *there* how are **you** doing?'), 'hey there how are you doing?'); @@ -53,14 +60,16 @@ void main() { expect(markdown('foxies\ncute'), 'foxies
\ncute'); }); test('emotes', () { - expect(markdown(':fox:', emotePacks), + expect(markdown(':fox:', emotePacks: emotePacks), ':fox:'); - expect(markdown(':user~fox:', emotePacks), + expect(markdown(':user~fox:', emotePacks: emotePacks), ':fox:'); - expect(markdown(':raccoon:', emotePacks), + expect(markdown(':raccoon:', emotePacks: emotePacks), ':raccoon:'); - expect(markdown(':invalid:', emotePacks), ':invalid:'); - expect(markdown(':room~invalid:', emotePacks), ':room~invalid:'); + expect(markdown(':invalid:', emotePacks: emotePacks), ':invalid:'); + expect(markdown(':invalid:?!', emotePacks: emotePacks), ':invalid:?!'); + expect( + markdown(':room~invalid:', emotePacks: emotePacks), ':room~invalid:'); }); test('pills', () { expect(markdown('Hey @sorunome:sorunome.de!'), @@ -71,6 +80,26 @@ void main() { '!blah:example.org'); expect(markdown('https://matrix.to/#/#fox:sorunome.de'), 'https://matrix.to/#/#fox:sorunome.de'); + expect(markdown('Hey @sorunome:sorunome.de:1234!'), + 'Hey @sorunome:sorunome.de:1234!'); + expect(markdown('Hey @sorunome:127.0.0.1!'), + 'Hey @sorunome:127.0.0.1!'); + expect(markdown('Hey @sorunome:[::1]!'), + 'Hey @sorunome:[::1]!'); + }); + test('mentions', () { + expect(markdown('Hey @Bob!', mentionMap: mentionMap), + 'Hey @Bob!'); + expect(markdown('How is @[Bob Ross] doing?', mentionMap: mentionMap), + 'How is @[Bob Ross] doing?'); + expect( + markdown('Hey @invalid!', mentionMap: mentionMap), 'Hey @invalid!'); + expect(markdown('Hey @Fox#123!', mentionMap: mentionMap), + 'Hey @Fox#123!'); + expect(markdown('Hey @[Fast Fox]#123!', mentionMap: mentionMap), + 'Hey @[Fast Fox]#123!'); + expect(markdown('Hey @[">]!', mentionMap: mentionMap), + 'Hey @[">]!'); }); test('latex', () { expect(markdown('meep \$\\frac{2}{3}\$'), diff --git a/test/user_test.dart b/test/user_test.dart index f64f89dc..e83d1937 100644 --- a/test/user_test.dart +++ b/test/user_test.dart @@ -29,13 +29,23 @@ void main() { group('User', () { Logs().level = Level.error; final client = Client('testclient', httpClient: FakeMatrixApi()); + final room = Room(id: '!localpart:server.abc', client: client); final user1 = User( '@alice:example.com', membership: 'join', displayName: 'Alice M', avatarUrl: 'mxc://bla', - room: Room(id: '!localpart:server.abc', client: client), + room: room, ); + final user2 = User( + '@bob:example.com', + membership: 'join', + displayName: 'Bob', + avatarUrl: 'mxc://bla', + room: room, + ); + room.setState(user1); + room.setState(user2); setUp(() async { await client.checkHomeserver('https://fakeserver.notexisting', checkWellKnown: false); @@ -133,6 +143,21 @@ void main() { test('canChangePowerLevel', () async { expect(user1.canChangePowerLevel, false); }); + test('mention', () async { + expect(user1.mention, '@[Alice M]'); + expect(user2.mention, '@Bob'); + user1.content['displayname'] = '[Alice M]'; + expect(user1.mention, '@alice:example.com'); + user1.content['displayname'] = 'Alice:M'; + expect(user1.mention, '@alice:example.com'); + user1.content['displayname'] = 'Alice M'; + user2.content['displayname'] = 'Alice M'; + expect(user1.mention, '@[Alice M]#1745'); + user1.content['displayname'] = 'Bob'; + user2.content['displayname'] = 'Bob'; + expect(user1.mention, '@Bob#1745'); + user1.content['displayname'] = 'Alice M'; + }); test('dispose client', () async { await client.dispose(closeDatabase: true); });