From a1594fd9ac3b7d6e0864cb33ecc405b0736c08a9 Mon Sep 17 00:00:00 2001 From: Sorunome Date: Sun, 4 Jul 2021 15:39:57 +0200 Subject: [PATCH] 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! --- lib/matrix.dart | 1 + lib/src/room.dart | 90 +-------- lib/src/utils/image_pack_extension.dart | 94 +++++++++ lib/src/utils/markdown.dart | 6 +- pubspec.yaml | 3 +- test/image_pack_test.dart | 243 ++++++++++++++++++++++++ test/markdown_test.dart | 10 +- 7 files changed, 354 insertions(+), 93 deletions(-) create mode 100644 lib/src/utils/image_pack_extension.dart create mode 100644 test/image_pack_test.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index 520c74e6..426992c3 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -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'; diff --git a/lib/src/room.dart b/lib/src/room.dart index 297f0d8c..baaf106e 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -545,88 +545,9 @@ class Room { ); /// return all current emote packs for this room - Map> get emotePacks { - final packs = >{}; - final normalizeEmotePackName = (String name) { - name = name.replaceAll(' ', '-'); - name = name.replaceAll(RegExp(r'[^\w-]'), ''); - return name.toLowerCase(); - }; - final allMxcs = {}; // for easy dedupint - final addEmotePack = (String packName, Map 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] = {}; - } - 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> 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> emotePacks, + @deprecated Map> 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'
\n?'), '\n')) != event['body']) { diff --git a/lib/src/utils/image_pack_extension.dart b/lib/src/utils/image_pack_extension.dart new file mode 100644 index 00000000..05a5b0a0 --- /dev/null +++ b/lib/src/utils/image_pack_extension.dart @@ -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 . + */ + +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 getImagePacks([ImagePackUsage usage]) { + final allMxcs = {}; // used for easy deduplication + final packs = {}; + 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({}); + 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> getImagePacksFlat([ImagePackUsage usage]) => + getImagePacks(usage).map((k, v) => + MapEntry(k, v.images.map((k, v) => MapEntry(k, v.url.toString())))); +} diff --git a/lib/src/utils/markdown.dart b/lib/src/utils/markdown.dart index abf8ca52..a13a4bf9 100644 --- a/lib/src/utils/markdown.dart +++ b/lib/src/utils/markdown.dart @@ -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); diff --git a/pubspec.yaml b/pubspec.yaml index 25045030..ca6a3292 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/image_pack_test.dart b/test/image_pack_test.dart new file mode 100644 index 00000000..3d76e549 --- /dev/null +++ b/test/image_pack_test.dart @@ -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 . + */ + +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); + }); + }); +} diff --git a/test/markdown_test.dart b/test/markdown_test.dart index 35a97607..f509df08 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -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 = {