feat: Add general image pack handling as per MSC2545

This also deprecates the old ways to access just emoticons, as the MSC
now covers both emoticons and stickers!
This commit is contained in:
Sorunome 2021-07-04 15:39:57 +02:00
parent ee287a09b9
commit a1594fd9ac
No known key found for this signature in database
GPG Key ID: B19471D07FC9BE9C
7 changed files with 354 additions and 93 deletions

View File

@ -22,6 +22,7 @@ library matrix;
export 'package:matrix_api_lite/matrix_api_lite.dart';
export 'src/utils/room_update.dart';
export 'src/utils/event_update.dart';
export 'src/utils/image_pack_extension.dart';
export 'src/utils/device_keys_list.dart';
export 'src/utils/matrix_file.dart';
export 'src/utils/matrix_id_string_extension.dart';

View File

@ -545,88 +545,9 @@ class Room {
);
/// return all current emote packs for this room
Map<String, Map<String, String>> get emotePacks {
final packs = <String, Map<String, String>>{};
final normalizeEmotePackName = (String name) {
name = name.replaceAll(' ', '-');
name = name.replaceAll(RegExp(r'[^\w-]'), '');
return name.toLowerCase();
};
final allMxcs = <String>{}; // for easy dedupint
final addEmotePack = (String packName, Map<String, dynamic> content,
[String packNameOverride]) {
if (!(content['emoticons'] is Map) && !(content['short'] is Map)) {
return;
}
if (content['pack'] is Map && content['pack']['short'] is String) {
packName = content['pack']['short'];
}
if (packNameOverride != null && packNameOverride.isNotEmpty) {
packName = packNameOverride;
}
packName = normalizeEmotePackName(packName);
if (!packs.containsKey(packName)) {
packs[packName] = <String, String>{};
}
if (content['emoticons'] is Map) {
content['emoticons'].forEach((key, value) {
if (key is String &&
value is Map &&
value['url'] is String &&
value['url'].startsWith('mxc://')) {
if (allMxcs.add(value['url'])) {
packs[packName][key] = value['url'];
}
}
});
} else {
content['short'].forEach((key, value) {
if (key is String && value is String && value.startsWith('mxc://')) {
if (allMxcs.add(value)) {
packs[packName][key] = value;
}
}
});
}
};
// first add all the user emotes
final userEmotes = client.accountData['im.ponies.user_emotes'];
if (userEmotes != null) {
addEmotePack('user', userEmotes.content);
}
// next add all the external emote rooms
final emoteRooms = client.accountData['im.ponies.emote_rooms'];
if (emoteRooms != null && emoteRooms.content['rooms'] is Map) {
for (final roomEntry in emoteRooms.content['rooms'].entries) {
final roomId = roomEntry.key;
final room = client.getRoomById(roomId);
if (room != null && roomEntry.value is Map) {
for (final stateKeyEntry in roomEntry.value.entries) {
final stateKey = stateKeyEntry.key;
final event = room.getState('im.ponies.room_emotes', stateKey);
if (event != null && stateKeyEntry.value is Map) {
addEmotePack(
(room.canonicalAlias?.isEmpty ?? true)
? room.id
: room.canonicalAlias,
event.content,
stateKeyEntry.value['name']);
}
}
}
}
}
// finally add all the room emotes
final allRoomEmotes = states['im.ponies.room_emotes'];
if (allRoomEmotes != null) {
for (final entry in allRoomEmotes.entries) {
final stateKey = entry.key;
final event = entry.value;
addEmotePack(stateKey.isEmpty ? 'room' : stateKey, event.content);
}
}
return packs;
}
@deprecated
Map<String, Map<String, String>> get emotePacks =>
getImagePacksFlat(ImagePackUsage.emoticon);
/// Sends a normal text message to this room. Returns the event ID generated
/// by the server for this message.
@ -635,7 +556,7 @@ class Room {
Event inReplyTo,
String editEventId,
bool parseMarkdown = true,
Map<String, Map<String, String>> emotePacks,
@deprecated Map<String, Map<String, String>> emotePacks,
bool parseCommands = true,
String msgtype = MessageTypes.Text}) {
if (parseCommands) {
@ -651,7 +572,8 @@ class Room {
for (final user in getParticipants()) user.mention: user.id
};
final html = markdown(event['body'],
emotePacks: emotePacks ?? this.emotePacks, mentionMap: mentionMap);
emotePacks: getImagePacksFlat(ImagePackUsage.emoticon),
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

@ -0,0 +1,94 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2020, 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:slugify/slugify.dart';
import 'package:matrix_api_lite/matrix_api_lite.dart';
import '../room.dart';
extension ImagePackRoomExtension on Room {
/// Get all the active image packs for the specified [usage], mapped by their slug
Map<String, ImagePackContent> getImagePacks([ImagePackUsage usage]) {
final allMxcs = <Uri>{}; // used for easy deduplication
final packs = <String, ImagePackContent>{};
final addImagePack = (BasicEvent event, {Room room, String slug}) {
if (event == null) return;
final imagePack = event.parsedImagePackContent;
final finalSlug = slugify(slug ?? 'pack');
for (final entry in imagePack.images.entries) {
final image = entry.value;
if (allMxcs.contains(image.url)) {
continue;
}
final imageUsage = image.usage ?? imagePack.pack.usage;
if (usage != null &&
imageUsage != null &&
!imageUsage.contains(usage)) {
continue;
}
if (!packs.containsKey(finalSlug)) {
packs[finalSlug] = ImagePackContent.fromJson(<String, dynamic>{});
packs[finalSlug].pack.displayName = imagePack.pack.displayName ??
room?.displayname ??
finalSlug ??
'';
packs[finalSlug].pack.avatarUrl =
imagePack.pack.avatarUrl ?? room?.avatar;
packs[finalSlug].pack.attribution = imagePack.pack.attribution;
}
packs[finalSlug].images[entry.key] = image;
allMxcs.add(image.url);
}
};
// first we add the user image pack
addImagePack(client.accountData['im.ponies.user_emotes'], slug: 'user');
// next we add all the external image packs
final packRooms = client.accountData['im.ponies.emote_rooms'];
if (packRooms != null && packRooms.content['rooms'] is Map) {
for (final roomEntry in packRooms.content['rooms'].entries) {
final roomId = roomEntry.key;
final room = client.getRoomById(roomId);
if (room != null && roomEntry.value is Map) {
for (final stateKeyEntry in roomEntry.value.entries) {
final stateKey = stateKeyEntry.key;
final fallbackSlug =
'${room.displayname}-${stateKey.isNotEmpty ? '$stateKey-' : ''}${room.id}';
addImagePack(room.getState('im.ponies.room_emotes', stateKey),
room: room, slug: fallbackSlug);
}
}
}
}
// finally we add all of this rooms state
final allRoomEmotes = states['im.ponies.room_emotes'];
if (allRoomEmotes != null) {
for (final entry in allRoomEmotes.entries) {
addImagePack(entry.value,
room: this,
slug: entry.value.stateKey.isEmpty ? 'room' : entry.value.stateKey);
}
}
return packs;
}
/// Get a flat view of all the image packs of a specified [usage], that is a map of all
/// slugs to a map of the image code to their mxc url
Map<String, Map<String, String>> getImagePacksFlat([ImagePackUsage usage]) =>
getImagePacks(usage).map((k, v) =>
MapEntry(k, v.images.map((k, v) => MapEntry(k, v.url.toString()))));
}

View File

@ -63,7 +63,7 @@ class EmoteSyntax extends InlineSyntax {
@override
bool onMatch(InlineParser parser, Match match) {
final pack = match[1] ?? '';
final emote = ':${match[2]}:';
final emote = match[2];
String mxc;
if (pack.isEmpty) {
// search all packs
@ -84,8 +84,8 @@ class EmoteSyntax extends InlineSyntax {
final element = Element.empty('img');
element.attributes['data-mx-emoticon'] = '';
element.attributes['src'] = htmlAttrEscape.convert(mxc);
element.attributes['alt'] = htmlAttrEscape.convert(emote);
element.attributes['title'] = htmlAttrEscape.convert(emote);
element.attributes['alt'] = htmlAttrEscape.convert(':$emote:');
element.attributes['title'] = htmlAttrEscape.convert(':$emote:');
element.attributes['height'] = '32';
element.attributes['vertical-align'] = 'middle';
parser.addNode(element);

View File

@ -18,10 +18,11 @@ dependencies:
base58check: ^2.0.0
olm: ^2.0.0
isolate: ^2.0.3
matrix_api_lite: ^0.3.3
matrix_api_lite: ^0.3.5
hive: ^2.0.4
ffi: ^1.0.0
js: ^0.6.3
slugify: ^2.0.0
dev_dependencies:
pedantic: ^1.11.0

243
test/image_pack_test.dart Normal file
View File

@ -0,0 +1,243 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:test/test.dart';
import 'package:matrix/matrix.dart';
import 'fake_client.dart';
void main() {
group('Image Pack', () {
Client client;
Room room;
Room room2;
var sortOrder = 0.0;
test('setupClient', () async {
client = await getClient();
room = Room(id: '!1234:fakeServer.notExisting', client: client);
room2 = Room(id: '!abcd:fakeServer.notExisting', client: client);
room.setState(Event(
type: 'm.room.power_levels',
content: {},
room: room,
stateKey: '',
));
room.setState(Event(
type: 'm.room.member',
content: {'membership': 'join'},
room: room,
stateKey: client.userID,
));
room2.setState(Event(
type: 'm.room.power_levels',
content: {},
room: room,
stateKey: '',
));
room2.setState(Event(
type: 'm.room.member',
content: {'membership': 'join'},
room: room,
stateKey: client.userID,
));
client.rooms.add(room);
client.rooms.add(room2);
});
test('Single room', () async {
room.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'room_plain': {'url': 'mxc://room_plain'}
}
},
room: room,
stateKey: '',
sortOrder: sortOrder++,
));
final packs = room.getImagePacks();
expect(packs.length, 1);
expect(packs['room'].images.length, 1);
expect(packs['room'].images['room_plain'].url.toString(),
'mxc://room_plain');
var packsFlat = room.getImagePacksFlat();
expect(packsFlat, {
'room': {'room_plain': 'mxc://room_plain'}
});
room.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'emote': {
'url': 'mxc://emote',
'usage': ['emoticon']
},
'sticker': {
'url': 'mxc://sticker',
'usage': ['sticker']
},
}
},
room: room,
stateKey: '',
sortOrder: sortOrder++,
));
packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote'}
});
packsFlat = room.getImagePacksFlat(ImagePackUsage.sticker);
expect(packsFlat, {
'room': {'sticker': 'mxc://sticker'}
});
room.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'emote': {'url': 'mxc://emote'},
'sticker': {'url': 'mxc://sticker'},
},
'pack': {
'usage': ['emoticon'],
}
},
room: room,
stateKey: '',
sortOrder: sortOrder++,
));
packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote', 'sticker': 'mxc://sticker'}
});
packsFlat = room.getImagePacksFlat(ImagePackUsage.sticker);
expect(packsFlat, {});
room.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'fox': {'url': 'mxc://fox'},
},
'pack': {
'usage': ['emoticon'],
}
},
room: room,
stateKey: 'fox',
sortOrder: sortOrder++,
));
packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote', 'sticker': 'mxc://sticker'},
'fox': {'fox': 'mxc://fox'},
});
});
test('user pack', () async {
client.accountData['im.ponies.user_emotes'] = BasicEvent.fromJson({
'type': 'im.ponies.user_emotes',
'content': {
'images': {
'user': {
'url': 'mxc://user',
}
},
},
});
final packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote', 'sticker': 'mxc://sticker'},
'fox': {'fox': 'mxc://fox'},
'user': {'user': 'mxc://user'},
});
});
test('other rooms', () async {
room2.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'other_room_emote': {'url': 'mxc://other_room_emote'},
},
'pack': {
'usage': ['emoticon'],
}
},
room: room2,
stateKey: '',
sortOrder: sortOrder++,
));
client.accountData['im.ponies.emote_rooms'] = BasicEvent.fromJson({
'type': 'im.ponies.emote_rooms',
'content': {
'rooms': {
'!abcd:fakeServer.notExisting': {'': {}},
},
},
});
var packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote', 'sticker': 'mxc://sticker'},
'fox': {'fox': 'mxc://fox'},
'user': {'user': 'mxc://user'},
'empty-chat-abcdfakeservernotexisting': {
'other_room_emote': 'mxc://other_room_emote'
},
});
room2.setState(Event(
type: 'im.ponies.room_emotes',
content: {
'images': {
'other_fox': {'url': 'mxc://other_fox'},
},
'pack': {
'usage': ['emoticon'],
}
},
room: room2,
stateKey: 'fox',
sortOrder: sortOrder++,
));
client.accountData['im.ponies.emote_rooms'] = BasicEvent.fromJson({
'type': 'im.ponies.emote_rooms',
'content': {
'rooms': {
'!abcd:fakeServer.notExisting': {'': {}, 'fox': {}},
},
},
});
packsFlat = room.getImagePacksFlat(ImagePackUsage.emoticon);
expect(packsFlat, {
'room': {'emote': 'mxc://emote', 'sticker': 'mxc://sticker'},
'fox': {'fox': 'mxc://fox'},
'user': {'user': 'mxc://user'},
'empty-chat-abcdfakeservernotexisting': {
'other_room_emote': 'mxc://other_room_emote'
},
'empty-chat-fox-abcdfakeservernotexisting': {
'other_fox': 'mxc://other_fox'
},
});
});
test('dispose client', () async {
await client.dispose(closeDatabase: true);
});
});
}

View File

@ -23,13 +23,13 @@ void main() {
group('markdown', () {
final emotePacks = {
'room': {
':fox:': 'mxc://roomfox',
':bunny:': 'mxc://roombunny',
'fox': 'mxc://roomfox',
'bunny': 'mxc://roombunny',
},
'user': {
':fox:': 'mxc://userfox',
':bunny:': 'mxc://userbunny',
':raccoon:': 'mxc://raccoon',
'fox': 'mxc://userfox',
'bunny': 'mxc://userbunny',
'raccoon': 'mxc://raccoon',
},
};
final mentionMap = {