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:
parent
44b7c96d73
commit
ee287a09b9
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">@[">]</a>!');
|
||||
});
|
||||
test('latex', () {
|
||||
expect(markdown('meep \$\\frac{2}{3}\$'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue