diff --git a/lib/controllers/all_emoji.dart b/lib/controllers/all_emoji.dart new file mode 100644 index 0000000..5703356 --- /dev/null +++ b/lib/controllers/all_emoji.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; + +class AllEmojiController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final packs = await ref.watch(ImagePacksController.provider(roomId).future); + final unicode = await ref.watch(EmojiController.provider.future); + + return packs + .map((pack) => pack.emojiCategory) + .nonNulls + .toIList() + .addAll(unicode); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + AllEmojiController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 569138f..3c16024 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart"; import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; @@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier { ...json, "content": { ...content, - "m.relates_to": { - ...((content["m.relates_to"] as Map?) ?? {}), - "event_id": request.relatesTo, - "rel_type": request.relationType, - }, + if (request.relatesTo != null) + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, }, }), ); @@ -265,6 +267,15 @@ class ClientController extends AsyncNotifier { ); } + Future> getSpecificRoomState( + GetSpecificRoomStateRequest request, + ) async => .new( + ((await _sendCommand("get_specific_room_state", request.toJson())) + as List? ?? + []) + .map((event) => .fromJson(event)), + ); + Future?> getRelatedEvents( GetRelatedEventsRequest request, ) async { diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart new file mode 100644 index 0000000..db704f3 --- /dev/null +++ b/lib/controllers/image_pack_sources.dart @@ -0,0 +1,112 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +typedef ImagePackSource = ({String roomId, String? stateKey}); + +class ImagePackSourcesController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => + (data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ) ?? + const .empty(), + ), + ); + final spaceEdges = ref.watch(SpaceEdgesController.provider); + final directParents = ref.watch( + RoomsController.provider.select( + (rooms) => + rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ?? + const .empty(), + ), + ); + + Iterable parentsOf(String childId) => spaceEdges.entries + .where((entry) => entry.value.any((edge) => edge.childId == childId)) + .map((entry) => entry.key) + .followedBy(childId == roomId ? directParents : const []); + + ISet withAncestors(ISet spaces, String childId) => + parentsOf(childId).fold( + spaces, + (spaces, parentId) => spaces.contains(parentId) + ? spaces + : withAncestors(spaces.add(parentId), parentId), + ); + + final rooms = ref.read(RoomsController.provider); + final spaces = withAncestors( + const .empty(), + roomId, + ).remove(roomId).where(rooms.containsKey).toIList(); + + final client = ref.read(ClientController.provider.notifier); + final roomsNotifier = ref.read(RoomsController.provider.notifier); + + await Future.wait( + spaces + .where((spaceId) => !rooms[spaceId]!.hasFetchedState) + .map( + (spaceId) async => roomsNotifier.addState( + spaceId, + await client.getRoomState(.new(roomId: spaceId)), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack + final missingKeys = enabledPacks.entries + .where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true)) + .expand( + (entry) => entry.value + .where( + (stateKey) => packTypes.every( + (type) => + rooms[entry.key]?.state[type.type]?[stateKey] == null, + ), + ) + .expand( + (stateKey) => packTypes.map( + (type) => RoomStateKey( + roomId: entry.key, + type: type.type, + stateKey: stateKey, + ), + ), + ), + ) + .toIList(); + + if (missingKeys.isNotEmpty) { + roomsNotifier.addSpecificState( + await client.getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return .new([ + (roomId: roomId, stateKey: null), + ...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)), + ...enabledPacks.entries.expand( + (entry) => entry.value.map( + (stateKey) => (roomId: entry.key, stateKey: stateKey), + ), + ), + ]); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePackSourcesController.new, + ); +} diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart new file mode 100644 index 0000000..f59f491 --- /dev/null +++ b/lib/controllers/image_packs.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/image_pack_sources.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/image_pack.dart"; + +class ImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final sources = await ref.watch( + ImagePackSourcesController.provider(roomId).future, + ); + + final packs = ref.watch( + RoomsController.provider.select( + (rooms) => sources.expand((source) { + final room = rooms[source.roomId]; + if (room == null) return const []; + + final newPacks = room.state[EventType.imagePack.type]; + final legacyPacks = room.state[EventType.legacyImagePack.type]; + final roomName = room.metadata?.name ?? source.roomId; + + return switch (source.stateKey) { + final stateKey? => ISet([stateKey]), + null => ISet( + newPacks?.keys ?? const [], + ).addAll(legacyPacks?.keys ?? const []), + } + .map( + (stateKey) => + switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) { + final rowId? => switch (room.events[rowId]) { + Event( + redactedBy: null, + content: final ImagePackContent content, + ) => + ImagePack.fromContent( + content, + id: "${source.roomId}/$stateKey", + fallbackName: stateKey.isEmpty + ? roomName + : "$roomName - $stateKey", + ), + _ => null, + }, + null => null, + }, + ) + .nonNulls; + }).toIList(), + ), + ); + + return .new( + packs + .whereNoDuplicates(by: (pack) => pack.id) + .where( + (pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty, + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePacksController.new, + ); +} diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart index 8dd3e59..a091afb 100644 --- a/lib/controllers/recent_emoji.dart +++ b/lib/controllers/recent_emoji.dart @@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier> { ), ); - return .new(recents.where((recent) => emoji.contains(recent.emoji))); + return .new( + recents.where( + (recent) => + // Is unicode or custom emote + emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"), + ), + ); } Future add(String emoji) => ref diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index b8b688d..2860a60 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/redact_event.dart"; @@ -315,14 +317,24 @@ class RoomChatController(final ChatParams info) } } - Future sendReaction(String reaction, Event event) async { + /// Reacts to [event] with [reaction], which is an mxc URL for custom emoji, + /// with their [shortcode]. + Future sendReaction( + String reaction, + Event event, { + String? shortcode, + }) async { final client = ref.read(ClientController.provider.notifier); await client.sendEvent( .new( roomId: info.roomId, type: EventType.reaction.type, - content: ReactionContent(key: reaction), + // Wrapped in colons, like other clients send it + content: ReactionContent( + key: reaction, + shortcode: shortcode == null ? null : ":$shortcode:", + ), synchronous: true, disableEncryption: true, relatesTo: event.eventId, @@ -331,6 +343,33 @@ class RoomChatController(final ChatParams info) ); } + Future sendSticker(String shortcode, PackImage image) async { + final event = await ref + .read(ClientController.provider.notifier) + .sendEvent( + .new( + roomId: info.roomId, + type: EventType.sticker.type, + content: StickerContent( + body: image.body ?? shortcode, + info: image.info ?? const .new(), + url: image.url, + ), + ), + ); + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + events: .new({event.rowId: event}), + clientSticky: .new({event.rowId}), + ), + }), + ); + } + static final provider = AsyncNotifierProvider.family .autoDispose( RoomChatController.new, diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 5370bcf..d52b317 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -11,29 +11,35 @@ class RoomsController extends Notifier> { @override IMap build() => .new(); - void addState( - String roomId, - IList state, { - bool isMembers = false, - }) => update( - .new({ - roomId: Room( - events: .fromEntries(state.map((event) => .new(event.rowId, event))), - hasFetchedState: true, - hasFetchedMembers: isMembers, - state: .new( - state - .groupListsBy((event) => event.type) - .map( - (type, group) => .new( - type, - .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), - ), - ), - ), - ), - }), - .new(), + void addState(String roomId, IList state, {bool isMembers = false}) => + update( + .new({ + roomId: stateRoom(state) + .copyWith(hasFetchedState: true, hasFetchedMembers: isMembers), + }), + .new(), + ); + + void addSpecificState(Iterable state) => update( + .new( + state + .groupListsBy((event) => event.roomId) + .map((roomId, roomState) => .new(roomId, stateRoom(roomState))), + ), + ); + + static Room stateRoom(Iterable state) => Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), + ), + ), ); static IMap> mergeReceipts( diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 72ca2d2..54b952d 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -18,6 +18,7 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/emoji_picker.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/main.dart"; extension BuildEventOptions on Event { @@ -97,6 +98,8 @@ extension BuildEventOptions on Event { mainAxisAlignment: isDesktop ? .center : .spaceEvenly, children: [ ...{ + // Custom emoji are sent without their shortcode, as only + // their mxc URL is recorded ...ref .read(RecentEmojiController.provider) .map((entry) => entry.emoji), @@ -109,10 +112,15 @@ extension BuildEventOptions on Event { .take(isDesktop ? 3 : 5) .map( (emoji) => IconButton( - icon: Text( - emoji, - style: .new(fontSize: isDesktop ? null : 18), - ), + icon: emoji.startsWith("mxc://") + ? SizedBox.square( + dimension: isDesktop ? 18 : 24, + child: MxcEmoji(Uri.parse(emoji)), + ) + : Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), onPressed: () async { Navigator.of(context).pop(); await ref @@ -134,12 +142,13 @@ extension BuildEventOptions on Event { context.showAdaptiveSheet( (context) => EmojiPicker( + roomId: roomId, allowFreeText: true, - onSelection: (emoji) { + onSelection: (emoji, {shortcode}) { Navigator.of(context).pop(); ref .read(notifier) - .sendReaction(emoji, this) + .sendReaction(emoji, this, shortcode: shortcode) .onError(showError); }, ), diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart new file mode 100644 index 0000000..2b04cf1 --- /dev/null +++ b/lib/helpers/extensions/image_pack_emoji.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; + +extension ImagePackEmoji on ImagePack { + EmojiCategory? get emojiCategory => _category(emoticons); + EmojiCategory? get stickerCategory => _category(stickers); + + EmojiCategory? _category(IMap images) => images.isEmpty + ? null + : .new( + icon: MxcEmoji(avatar ?? images.values.first.url), + name: name, + emojis: images + .mapTo( + (shortcode, image) => Emoji( + widget: MxcEmoji(image.url, label: shortcode), + value: image.url.toString(), + aliases: .new([shortcode]), + description: image.body ?? shortcode, + tags: const .empty(), + data: image, + ), + ) + .toIList(), + ); +} diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 0ab5990..f4dee1a 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -16,10 +16,24 @@ class const AccountData({ @JsonKey(name: AccountData.recentEmojiKey) final RecentEmojiData recentEmoji = const RecentEmojiData(), + + /// Room image packs the user has enabled everywhere + @JsonKey( + name: AccountData.imagePackRoomsKey, + fromJson: ImagePackRooms.tryFromJson, + ) + final ImagePackRooms? imagePackRooms, + @JsonKey( + name: AccountData.legacyImagePackRoomsKey, + fromJson: ImagePackRooms.tryFromJson, + ) + final ImagePackRooms? legacyImagePackRooms, }) with _$AccountData { static const invitePermissionConfigKey = "m.invite_permission_config"; static const directKey = "m.direct"; static const recentEmojiKey = "m.recent_emoji"; + static const imagePackRoomsKey = "m.image_pack.rooms"; + static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; Map toJson() => _$AccountDataToJson(this); @@ -38,6 +52,28 @@ class const RecentEmojiData({ _$RecentEmojiDataFromJson(json); } +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImagePackRooms({ + /// Room IDs to the state keys of their enabled packs, each to a (currently + /// empty) object + final IMap>> rooms = const .empty(), +}) with _$ImagePackRooms { + static ImagePackRooms? tryFromJson(Map? json) { + if (json == null) return null; + try { + return .fromJson(json); + } catch (_) { + return null; + } + } + + Map toJson() => _$ImagePackRoomsToJson(this); + + factory ImagePackRooms.fromJson(Map json) => + _$ImagePackRoomsFromJson(json); +} + @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const InvitePermissionConfig({ diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index e6061d1..2809245 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -16,6 +16,7 @@ import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; class const Content({final String? parseError, final StackTrace? stackTrace}) { factory Content.fromJson(Map json) => const Content(); @@ -61,5 +62,7 @@ enum EventType( tombstone("m.room.tombstone", Content.fromJson), reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + imagePack("m.room.image_pack", ImagePackContent.fromJson), + legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), message("m.room.message", MessageContent.fromJson), } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart new file mode 100644 index 0000000..5a62fc2 --- /dev/null +++ b/lib/models/content/image_pack.dart @@ -0,0 +1,68 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "image_pack.freezed.dart"; +part "image_pack.g.dart"; + +/// An image pack (MSC2545), from a room's state. +@freezed +sealed class ImagePackContent extends Content with _$ImagePackContent { + ImagePackContent._(); + factory ImagePackContent({ + @Default(IMap.empty()) IMap images, + PackInfo? pack, + }) = _ImagePackContent; + + IMap imagesFor(ImageUsage usage) => images.where( + (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { + (final usages, _) when usages.isNotEmpty => usages.contains(usage), + (_, final packUsages) when packUsages.isNotEmpty => packUsages.contains( + usage, + ), + _ => true, + }, + ); + + factory ImagePackContent.fromJson(Map json) => + _$ImagePackContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PackImage({ + required final Uri url, + final String? body, + final ImageInfo? info, + @JsonKey(fromJson: ImageUsage.setFromJson) + final ISet usage = const .empty(), +}) with _$PackImage { + Map toJson() => _$PackImageToJson(this); + + factory PackImage.fromJson(Map json) => + _$PackImageFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PackInfo({ + final String? displayName, + final Uri? avatarUrl, + final String? attribution, + @JsonKey(fromJson: ImageUsage.setFromJson) + final ISet usage = const .empty(), +}) with _$PackInfo { + Map toJson() => _$PackInfoToJson(this); + + factory PackInfo.fromJson(Map json) => + _$PackInfoFromJson(json); +} + +enum ImageUsage { + emoticon, + sticker; + + /// Ignores usages we don't know about. + static ISet setFromJson(List? json) => + ISet(values.where((value) => json?.contains(value.name) ?? false)); +} diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 93c563d..347350d 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -11,11 +11,15 @@ sealed class ReactionContent extends Content with _$ReactionContent { factory ReactionContent({ @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + + /// For custom emoji reactions + @JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode, }) = _ReactionContent; @override Map toJson() => { "m.relates_to": {"key": key}, + "com.beeper.reaction.shortcode": ?shortcode, }; factory ReactionContent.fromJson(Map json) => diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart new file mode 100644 index 0000000..bfe1af0 --- /dev/null +++ b/lib/models/image_pack.dart @@ -0,0 +1,33 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/image_pack.dart"; + +part "image_pack.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ImagePack({ + required final String id, + required final String name, + final Uri? avatar, + + /// Map of shortcode to images + required final IMap emoticons, + required final IMap stickers, +}) with _$ImagePack { + static String emojiMarkdown(String shortcode, String url) { + final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"'); + return '![:$shortcode:]($url "Emoji: $title")'; + } + + factory ImagePack.fromContent( + ImagePackContent content, { + required String id, + required String fallbackName, + }) => .new( + id: id, + name: content.pack?.displayName ?? fallbackName, + avatar: content.pack?.avatarUrl, + emoticons: content.imagesFor(.emoticon), + stickers: content.imagesFor(.sticker), + ); +} diff --git a/lib/models/requests/get_specific_room_state.dart b/lib/models/requests/get_specific_room_state.dart new file mode 100644 index 0000000..1559bc0 --- /dev/null +++ b/lib/models/requests/get_specific_room_state.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_specific_room_state.freezed.dart"; +part "get_specific_room_state.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetSpecificRoomStateRequest({ + required final IList keys, +}) with _$GetSpecificRoomStateRequest { + Map toJson() => _$GetSpecificRoomStateRequestToJson(this); + + factory GetSpecificRoomStateRequest.fromJson(Map json) => + _$GetSpecificRoomStateRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomStateKey({ + required final String roomId, + required final String type, + required final String stateKey, +}) with _$RoomStateKey { + Map toJson() => _$RoomStateKeyToJson(this); + + factory RoomStateKey.fromJson(Map json) => + _$RoomStateKeyFromJson(json); +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 2648347..5f79960 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -17,7 +17,11 @@ import "package:nexus/controllers/image_picker.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/paste_files_action.dart"; @@ -38,6 +42,8 @@ class const Composer( required IList tags, }) onSend, + required final Future Function(String shortcode, PackImage image) + onSendSticker, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -48,6 +54,17 @@ class const Composer( final triggerCharacter = useState(""); final shouldMention = useState(true); final query = useState(""); + final canSendStickers = + ref.watch( + PowerLevelController.provider( + .new(eventType: .sticker, roomId: roomId), + ), + ) && + (ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.any((pack) => pack.stickers.isNotEmpty) ?? + false); final relatedEvent = this.relatedEvent == null ? null : ref.watch( @@ -253,9 +270,27 @@ class const Composer( IconButton( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( - onSelection: (value) { + roomId: roomId, + onSelection: (value, {shortcode}) { Navigator.of(context).pop(); - controller.text += value; + if (shortcode == null) { + controller.text += value; + } else { + final text = "${controller.text}:"; + controller.value = .new( + text: text, + selection: .collapsed( + offset: text.length, + ), + ); + controller.addTag( + id: ImagePack.emojiMarkdown( + shortcode, + value, + ), + name: shortcode, + ); + } node?.requestFocus(); }, ), @@ -308,6 +343,22 @@ class const Composer( leading: Icon(Icons.attachment), ), ), + if (canSendStickers) + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (context) => StickerPicker( + roomId: roomId, + onSelection: (shortcode, image) { + Navigator.of(context).pop(); + onSendSticker(shortcode, image); + }, + ), + ), + child: ListTile( + title: Text("Sticker"), + leading: Icon(Icons.sticky_note_2), + ), + ), ], icon: Icon(Icons.add), ), @@ -315,6 +366,9 @@ class const Composer( Expanded( child: FlutterTagger( triggerStrategy: .eager, + // Characters that continue a search, for + // e.g. :+1, :custom_emoji or @user.name + searchRegex: .new(r"^[\w.+-]*$"), overlay: TaggerOverlay( query.value, roomId: roomId, diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index 0e762c0..a360604 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -1,8 +1,9 @@ import "package:collection/collection.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:material_emoji_picker/material_emoji_picker.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/image_pack.dart"; class const EmojiOverlay( final String query, { @@ -13,7 +14,7 @@ class const EmojiOverlay( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(EmojiController.provider) + .watch(AllEmojiController.provider(roomId)) .betterWhen( data: (emojis) => ListView( children: emojis @@ -34,8 +35,11 @@ class const EmojiOverlay( title: Text(emoji.aliases.first), subtitle: Text(emoji.description), onTap: () => addTag( - id: Uri.tryParse(emoji.value)?.scheme == "mxc" - ? "" // TODO: Handle custom emotes + id: emoji.value.startsWith("mxc://") + ? ImagePack.emojiMarkdown( + emoji.aliases.first, + emoji.value, + ) : emoji.value, name: emoji.aliases.first, ), diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 7e49836..4a64f38 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -1,15 +1,23 @@ import "dart:async"; import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_emoji_picker/material_emoji_picker.dart" as upstream; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; +import "package:nexus/controllers/image_packs.dart"; import "package:nexus/controllers/recent_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; class const EmojiPicker({ - required final FutureOr Function(String value) onSelection, + required final String roomId, + + required final FutureOr Function(String value, {String? shortcode}) + onSelection, final bool allowFreeText = false, super.key, }) extends ConsumerWidget { @@ -18,39 +26,51 @@ class const EmojiPicker({ final recentEmoji = ref.watch(RecentEmojiController.provider); return ref - .watch(upstream.EmojiController.provider) + .watch(AllEmojiController.provider(roomId)) .betterWhen( - data: (categories) => upstream.EmojiPicker( - onSelection: (emoji, isFreeText) async { - await onSelection(emoji); + data: (categories) { + final allEmoji = categories.expand((category) => category.emojis); + final customCategories = + ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.map((pack) => pack.emojiCategory) + .nonNulls + .toIList() ?? + const .empty(); + + return upstream.EmojiPicker( + onSelection: (emoji) async { + await onSelection( + emoji.value, + shortcode: emoji.data is PackImage + ? emoji.aliases.first + : null, + ); - if (!isFreeText) { await ref .read(RecentEmojiController.provider.notifier) - .add(emoji) + .add(emoji.value) .onError(showError); - } - }, - allowFreeText: allowFreeText, - prependCategories: .new([ - .new( - icon: Icon(Icons.history), - name: "Recent", - emojis: .new( - recentEmoji - .map( - (recent) => categories - .map((element) => element.emojis) - .flattened - .firstWhereOrNull( - (element) => element.value == recent.emoji, - ), - ) - .nonNulls, + }, + onFreeText: allowFreeText ? (text) => onSelection(text) : null, + prependCategories: IList([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => allEmoji.firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), ), - ), - ]), - ), + ]).addAll(customCategories), + ); + }, ); } } diff --git a/lib/widgets/mxc_emoji.dart b/lib/widgets/mxc_emoji.dart new file mode 100644 index 0000000..e8c4916 --- /dev/null +++ b/lib/widgets/mxc_emoji.dart @@ -0,0 +1,16 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A custom emoji or sticker from an image pack, scaled to fit its parent. +class const MxcEmoji(final Uri url, {final String? label, super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => Image( + image: MxcImage(ref, .new(mxc: url)), + semanticLabel: label, + fit: .contain, + errorBuilder: (context, _, _) => + Icon(Icons.broken_image, color: Theme.of(context).colorScheme.error), + ); +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index e7fd6e0..db7120c 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -323,6 +323,10 @@ final class const RoomChat({ relatedEvent.value, ) .onError(showError), + onSendSticker: (shortcode, image) => + notifier + .sendSticker(shortcode, image) + .onError(showError), relationType: relationType.value, relatedEvent: relatedEvent.value, onDismiss: () { diff --git a/lib/widgets/sticker_picker.dart b/lib/widgets/sticker_picker.dart new file mode 100644 index 0000000..3222399 --- /dev/null +++ b/lib/widgets/sticker_picker.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Stickers from the image packs available in [roomId], grouped by pack. +class const StickerPicker({ + required final String roomId, + required final FutureOr Function(String shortcode, PackImage image) + onSelection, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(ImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) { + final categories = packs + .map((pack) => pack.stickerCategory) + .nonNulls + .toIList(); + + return categories.isEmpty + ? Padding( + padding: .all(16), + child: Center(child: Text("No sticker packs available")), + ) + : EmojiPicker( + prependCategories: categories, + includeDefaultEmoji: false, + itemSize: 96, + searchHint: "Search stickers", + onSelection: (sticker) { + if (sticker.data case final PackImage image) { + return onSelection(sticker.aliases.first, image); + } + }, + ); + }, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 670b2ca..fef5cfa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -924,10 +924,10 @@ packages: dependency: "direct main" description: name: material_emoji_picker - sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138" + sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "2.0.1" material_ui: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 11c4808..c49c98e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -111,7 +111,7 @@ dependencies: unifiedpush_storage_shared_preferences: 1.0.0 flutter_local_notifications: 22.3.1 timezone: 0.11.1 - material_emoji_picker: 1.2.0 + material_emoji_picker: 2.0.1 xdg_desktop_portal: git: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart