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.
This commit is contained in:
Sorunome 2021-07-18 13:22:49 +02:00
parent 44b7c96d73
commit ee287a09b9
No known key found for this signature in database
GPG Key ID: B19471D07FC9BE9C
5 changed files with 149 additions and 19 deletions

View File

@ -647,7 +647,11 @@ class Room {
'body': message,
};
if (parseMarkdown) {
final html = markdown(event['body'], emotePacks ?? this.emotePacks);
final mentionMap = <String, String>{
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'<br />\n?'), '\n')) !=
event['body']) {

View File

@ -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();
}

View File

@ -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<String, Map<String, String>> emotePacks]) {
emotePacks ??= <String, Map<String, String>>{};
class MentionSyntax extends InlineSyntax {
final Map<String, String> 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<String, Map<String, String>> emotePacks,
Map<String, String> mentionMap}) {
var ret = markdownToHtml(
text,
extensionSet: ExtensionSet.commonMark,
@ -175,8 +200,9 @@ String markdown(String text, [Map<String, Map<String, String>> emotePacks]) {
StrikethroughSyntax(),
LinebreakSyntax(),
SpoilerSyntax(),
EmoteSyntax(emotePacks),
EmoteSyntax(emotePacks ?? <String, Map<String, String>>{}),
PillSyntax(),
MentionSyntax(mentionMap ?? <String, String>{}),
InlineLatexSyntax(),
],
);

View File

@ -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 <em>there</em> how are <strong>you</strong> doing?');
@ -53,14 +60,16 @@ void main() {
expect(markdown('foxies\ncute'), 'foxies<br />\ncute');
});
test('emotes', () {
expect(markdown(':fox:', emotePacks),
expect(markdown(':fox:', emotePacks: emotePacks),
'<img data-mx-emoticon="" src="mxc://roomfox" alt=":fox:" title=":fox:" height="32" vertical-align="middle" />');
expect(markdown(':user~fox:', emotePacks),
expect(markdown(':user~fox:', emotePacks: emotePacks),
'<img data-mx-emoticon="" src="mxc://userfox" alt=":fox:" title=":fox:" height="32" vertical-align="middle" />');
expect(markdown(':raccoon:', emotePacks),
expect(markdown(':raccoon:', emotePacks: emotePacks),
'<img data-mx-emoticon="" src="mxc://raccoon" alt=":raccoon:" title=":raccoon:" height="32" vertical-align="middle" />');
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() {
'<a href="https://matrix.to/#/!blah:example.org">!blah:example.org</a>');
expect(markdown('https://matrix.to/#/#fox:sorunome.de'),
'https://matrix.to/#/#fox:sorunome.de');
expect(markdown('Hey @sorunome:sorunome.de:1234!'),
'Hey <a href="https://matrix.to/#/@sorunome:sorunome.de:1234">@sorunome:sorunome.de:1234</a>!');
expect(markdown('Hey @sorunome:127.0.0.1!'),
'Hey <a href="https://matrix.to/#/@sorunome:127.0.0.1">@sorunome:127.0.0.1</a>!');
expect(markdown('Hey @sorunome:[::1]!'),
'Hey <a href="https://matrix.to/#/@sorunome:[::1]">@sorunome:[::1]</a>!');
});
test('mentions', () {
expect(markdown('Hey @Bob!', mentionMap: mentionMap),
'Hey <a href="https://matrix.to/#/@bob:example.org">@Bob</a>!');
expect(markdown('How is @[Bob Ross] doing?', mentionMap: mentionMap),
'How is <a href="https://matrix.to/#/@bobross:example.org">@[Bob Ross]</a> doing?');
expect(
markdown('Hey @invalid!', mentionMap: mentionMap), 'Hey @invalid!');
expect(markdown('Hey @Fox#123!', mentionMap: mentionMap),
'Hey <a href="https://matrix.to/#/@fox:example.org">@Fox#123</a>!');
expect(markdown('Hey @[Fast Fox]#123!', mentionMap: mentionMap),
'Hey <a href="https://matrix.to/#/@fastfox:example.org">@[Fast Fox]#123</a>!');
expect(markdown('Hey @[">]!', mentionMap: mentionMap),
'Hey <a href="https://matrix.to/#/@blah:example.org">@[&quot;&gt;]</a>!');
});
test('latex', () {
expect(markdown('meep \$\\frac{2}{3}\$'),

View File

@ -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);
});