diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index 551c0cc..d98e4ca 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,8 +1,10 @@ +import "dart:async"; import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/account_data.dart"; class AccountDataController extends Notifier { @@ -22,6 +24,44 @@ class AccountDataController extends Notifier { } }); + Future setEnabledImagePacks(ImagePackRooms packs) async { + ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries + .expand((entry) => entry.value.keys.map((key) => (entry.key, key))) + .toISet(); + final keys = keysOf(packs); + bool arrived(AccountData data) { + final current = keysOf(data.enabledImagePacks); + return current.length == keys.length && current.containsAll(keys); + } + + final completer = Completer(); + final removeListener = listenSelf((_, data) { + if (arrived(data) && !completer.isCompleted) completer.complete(); + }); + + try { + final client = ref.read(ClientController.provider.notifier); + await Future.wait([ + if (state.imagePackRooms != null) + client.setAccountData( + .new(type: AccountData.imagePackRoomsKey, content: packs), + ), + client.setAccountData( + .new(type: AccountData.legacyImagePackRoomsKey, content: packs), + ), + ]); + + if (!arrived(state)) { + await completer.future.timeout( + const .new(seconds: 10), + onTimeout: () {}, + ); + } + } finally { + removeListener(); + } + } + static final provider = NotifierProvider( AccountDataController.new, ); diff --git a/lib/controllers/enabled_image_packs.dart b/lib/controllers/enabled_image_packs.dart new file mode 100644 index 0000000..c16799c --- /dev/null +++ b/lib/controllers/enabled_image_packs.dart @@ -0,0 +1,69 @@ +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/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +/// The image packs the user has enabled everywhere, as room IDs to their +/// packs' state keys, once the packs' state has been loaded. +class EnabledImagePacksController + extends AsyncNotifier>> { + @override + Future>> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => data.enabledImagePacks.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack. + final missingKeys = ref.watch( + RoomsController.provider.select( + (rooms) => 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) { + ref + .read(RoomsController.provider.notifier) + .addSpecificState( + await ref + .read(ClientController.provider.notifier) + .getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return enabledPacks; + } + + static final provider = + AsyncNotifierProvider.autoDispose< + EnabledImagePacksController, + IMap> + >(EnabledImagePacksController.new); +} diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart index db704f3..61e4a2e 100644 --- a/lib/controllers/image_pack_sources.dart +++ b/lib/controllers/image_pack_sources.dart @@ -1,11 +1,9 @@ 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/enabled_image_packs.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}); @@ -13,14 +11,8 @@ 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 enabledPacksFuture = ref.watch( + EnabledImagePacksController.provider.future, ); final spaceEdges = ref.watch(SpaceEdgesController.provider); final directParents = ref.watch( @@ -63,36 +55,7 @@ class ImagePackSourcesController(final String roomId) ), ), ); - - 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)), - ); - } + final enabledPacks = await enabledPacksFuture; return .new([ (roomId: roomId, stateKey: null), diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart index f59f491..9248211 100644 --- a/lib/controllers/image_packs.dart +++ b/lib/controllers/image_packs.dart @@ -2,9 +2,7 @@ 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/helpers/extensions/room_image_packs.dart"; import "package:nexus/models/image_pack.dart"; class ImagePacksController(final String roomId) @@ -18,40 +16,13 @@ class ImagePacksController(final String roomId) 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; + final packs = + rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty(); 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; + final stateKey? => [?packs[stateKey]], + null => packs.values, + }; }).toIList(), ), ); diff --git a/lib/controllers/room_image_packs.dart b/lib/controllers/room_image_packs.dart new file mode 100644 index 0000000..318d388 --- /dev/null +++ b/lib/controllers/room_image_packs.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +/// The image packs in [roomId]'s own state by state key, loading its state if +/// it hasn't been yet. +class RoomImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + if (ref.read(RoomsController.provider)[roomId] case final room? + when !room.hasFetchedState) { + ref + .read(RoomsController.provider.notifier) + .addState( + roomId, + await ref + .read(ClientController.provider.notifier) + .getRoomState(.new(roomId: roomId)), + ); + } + + return ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(), + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + RoomImagePacksController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 6c266a9..a821164 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_image_packs.dart"; import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; @@ -21,6 +22,7 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; @@ -33,10 +35,12 @@ import "package:nexus/models/setting.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/image_pack_sheet.dart"; import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_tile.dart"; @@ -91,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId) final canSetJoinRules = canSet(.joinRules); final canSetHistoryVisibility = canSet(.historyVisibility); final canSetAliases = canSet(.canonicalAlias); + final canCreatePacks = canSet(.imagePack); final canSetPowerLevels = canSet(.powerLevels) && !ref.watch(PowerLevelsSavingController.provider(roomId)); @@ -161,15 +166,78 @@ class RoomSettingsSectionsController(final String roomId) final encrypted = stateContent(.encryption) is EncryptionContent; final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); - Future setState(EventType type, Content content) async { + Future setState( + EventType type, + Content content, { + String stateKey = "", + }) async { final eventId = await client.setState( - .new(roomId: roomId, type: type.type, stateKey: "", content: content), + .new( + roomId: roomId, + type: type.type, + stateKey: stateKey, + content: content, + ), ); if (eventId case final eventId?) { - await roomsController.awaitState(roomId, type.type, eventId); + await roomsController.awaitState( + roomId, + type.type, + eventId, + stateKey: stateKey, + ); } } + final imagePacks = + ref.watch(RoomImagePacksController.provider(roomId)).value ?? + const .empty(); + + // Pack sheets can stay open while the state changes, so these read it when + // called, rather than using the state from building + IMap> currentState() => + ref.read(RoomsController.provider)[roomId]?.state ?? const .empty(); + + // Whether the legacy type still has a pack at [stateKey], to clear it + bool hasLegacyPack(String stateKey) => + switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) { + final rowId? => switch (contentOf(rowId)) { + final ImagePackContent content => content.hasContent, + _ => false, + }, + null => false, + }; + final canSetLegacyPacks = canSet(.legacyImagePack); + + /// Saves the pack at [stateKey] as the stable type, migrating it by + /// clearing its legacy event if it has one. + Future setPack(String stateKey, Content content) async { + await setState(.imagePack, content, stateKey: stateKey); + if (hasLegacyPack(stateKey)) { + await setState(.legacyImagePack, const .new(), stateKey: stateKey); + } + } + + /// A state key for a new pack named [name]. It's one no pack has ever + /// had, even deleted ones, as users may still have those enabled. + String newPackKey(String name) { + final state = currentState(); + final usedKeys = [ + EventType.imagePack, + EventType.legacyImagePack, + ].expand((type) => state[type.type]?.keys ?? []).toISet(); + final slug = name + .toLowerCase() + .replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_") + .replaceAll(RegExp(r"^_+|_+$"), ""); + final base = slug.isEmpty ? "pack" : slug; + + return Iterable.generate( + usedKeys.length + 1, + (index) => index == 0 ? base : "${base}_${index + 1}", + ).firstWhere((key) => !usedKeys.contains(key)); + } + Future setAliases( String? canonical, ISet alternatives, @@ -817,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId) ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...imagePacks.entries.map((entry) { + final stateKey = entry.key; + final pack = entry.value; + final canEdit = + canCreatePacks && + (canSetLegacyPacks || !hasLegacyPack(stateKey)); + + return Setting( + title: pack.name, + description: pack.imageCountLabel, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey(stateKey), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(pack.icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: canEdit ? "Edit Image Pack" : title, + content: pack.content, + onSave: canEdit + ? (content) => saving.run( + () => setPack( + stateKey, + content, + ).onError(showError), + ) + : null, + onDeleted: canEdit + ? () => saving.run( + () => setPack( + stateKey, + const .new(), + ).onError(showError), + ) + : null, + ), + ), + ); + }, + ), + ); + }), + .new( + title: "Create Pack", + description: "Add a pack of emoticons and stickers to the room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: canCreatePacks && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: "Create Pack", + content: const .new(), + onSave: (content) => saving.run( + () => setState( + .imagePack, + content, + stateKey: newPackKey(content.pack!.displayName!), + ).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), ]), }); } diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 1a98a43..b74f50d 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,23 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/client_state.dart"; +import "package:nexus/models/image_pack.dart"; import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/setting.dart"; import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -36,6 +44,33 @@ class SettingsSectionsController Future>> build() async { final settings = await ref.watch(SettingsController.provider.future); + // Loads the enabled packs' state, so they can be shown + ref.watch(EnabledImagePacksController.provider); + final enabledPacks = ref.watch( + AccountDataController.provider.select((data) => data.enabledImagePacks), + ); + final enabledPackInfo = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.rooms.entries.expand((entry) { + final room = rooms[entry.key]; + final packs = room?.imagePacks(entry.key) ?? const .empty(); + + return entry.value.keys.map( + (stateKey) => ( + roomId: entry.key, + stateKey: stateKey, + roomName: room?.metadata?.name ?? entry.key, + pack: packs[stateKey], + ), + ); + }).toIList(), + ), + ); + + Future setEnabledPacks(ImagePackRooms packs) => ref + .read(AccountDataController.provider.notifier) + .setEnabledImagePacks(packs); + /// A tile for a [ShowAndSend] setting, saving the settings from [update]. Widget showAndSendTile( String title, @@ -300,6 +335,78 @@ class SettingsSectionsController ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...enabledPackInfo.map( + (info) => Setting( + title: + info.pack?.name ?? + ImagePack.fallbackName(info.roomName, info.stateKey), + description: info.roomName, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey((info.roomId, info.stateKey)), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(info.pack?.icon), + title: Text(title), + subtitle: Text(description), + trailing: IconButton( + tooltip: "Remove pack", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => saving.run( + () => setEnabledPacks( + ref + .read(AccountDataController.provider) + .enabledImagePacks + .withPack( + info.roomId, + info.stateKey, + enabled: false, + ), + ).onError(showError), + ), + ), + ); + }, + ), + ), + ), + .new( + title: "Add Packs", + description: + "Use image packs from any of your rooms in every room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => EnableImagePacksSheet( + enabled: enabledPacks, + onSave: (packs) => saving.run( + () => setEnabledPacks(packs).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), .new( title: "Notifications", icon: Icons.notifications, diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 54b952d..e96284b 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -144,7 +144,7 @@ extension BuildEventOptions on Event { (context) => EmojiPicker( roomId: roomId, allowFreeText: true, - onSelection: (emoji, {shortcode}) { + onSelection: (emoji, {shortcode, description}) { Navigator.of(context).pop(); ref .read(notifier) diff --git a/lib/helpers/extensions/room_image_packs.dart b/lib/helpers/extensions/room_image_packs.dart new file mode 100644 index 0000000..9b9d9ce --- /dev/null +++ b/lib/helpers/extensions/room_image_packs.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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"; +import "package:nexus/models/room.dart"; + +extension RoomImagePacks on Room { + /// The room's image packs by state key, preferring the stable type when a + /// pack has both, and leaving out deleted ones. [roomId] names those without + /// a name when the room has none either. + IMap imagePacks(String roomId) => .fromEntries( + const [EventType.legacyImagePack, EventType.imagePack].expand( + (type) => (state[type.type] ?? .new()).entries + .map( + (entry) => switch (events[entry.value]) { + Event(redactedBy: null, content: final ImagePackContent content) + when content.hasContent => + MapEntry( + entry.key, + ImagePack.fromContent( + content, + roomId: roomId, + roomName: metadata?.name ?? roomId, + stateKey: entry.key, + ), + ), + _ => null, + }, + ) + .nonNulls, + ), + ); +} diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 2e21937..88da5a0 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -29,6 +29,9 @@ class const AccountData({ static const imagePackRoomsKey = "m.image_pack.rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; + ImagePackRooms get enabledImagePacks => + imagePackRooms ?? legacyImagePackRooms ?? const .new(); + Map toJson() => _$AccountDataToJson(this); factory AccountData.fromJson(Map json) => @@ -53,6 +56,29 @@ class const ImagePackRooms({ /// empty) object final IMap>> rooms = const .empty(), }) with _$ImagePackRooms { + bool contains(String roomId, String stateKey) => + rooms[roomId]?.containsKey(stateKey) ?? false; + + ImagePackRooms withPack( + String roomId, + String stateKey, { + required bool enabled, + }) { + final packs = + rooms[roomId] ?? const IMap>.empty(); + final updated = switch ((enabled, packs.containsKey(stateKey))) { + (true, false) => packs.add(stateKey, const {}), + (false, true) => packs.remove(stateKey), + _ => packs, + }; + + return copyWith( + rooms: updated.isEmpty + ? rooms.remove(roomId) + : rooms.add(roomId, updated), + ); + } + Map toJson() => _$ImagePackRoomsToJson(this); factory ImagePackRooms.fromJson(Map json) => diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart index 9668fbe..5bcbb54 100644 --- a/lib/models/content/image_pack.dart +++ b/lib/models/content/image_pack.dart @@ -7,11 +7,14 @@ part "image_pack.g.dart"; /// An image pack (MSC2545), from a room's state. @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const ImagePackContent({ final IMap images = const .empty(), final PackInfo? pack, }) extends Content with _$ImagePackContent { + /// Whether the pack has images or a name, as deleted packs are left empty. + bool get hasContent => images.isNotEmpty || pack?.displayName != null; + IMap imagesFor(ImageUsage usage) => images.where( (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { (final usages, _) when usages.isNotEmpty => usages.contains(usage), @@ -30,12 +33,12 @@ class const ImagePackContent({ } @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const PackImage({ required final Uri url, final String? body, final ImageInfo? info, - @JsonKey(fromJson: ImageUsage.setFromJson) + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) final ISet usage = const .empty(), }) with _$PackImage { Map toJson() => _$PackImageToJson(this); @@ -45,12 +48,12 @@ class const PackImage({ } @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class const PackInfo({ final String? displayName, final Uri? avatarUrl, final String? attribution, - @JsonKey(fromJson: ImageUsage.setFromJson) + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) final ISet usage = const .empty(), }) with _$PackInfo { Map toJson() => _$PackInfoToJson(this); @@ -66,4 +69,8 @@ enum ImageUsage { /// Ignores usages we don't know about. static ISet setFromJson(List? json) => ISet(values.where((value) => json?.contains(value.name) ?? false)); + + /// Omits an empty set, which means every usage. + static List? setToJson(ISet usages) => + usages.isEmpty ? null : usages.map((usage) => usage.name).toList(); } diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart index bfe1af0..33528c2 100644 --- a/lib/models/image_pack.dart +++ b/lib/models/image_pack.dart @@ -13,21 +13,42 @@ class const ImagePack({ /// Map of shortcode to images required final IMap emoticons, required final IMap stickers, + + /// The state event the pack is from, for editing it. + required final ImagePackContent content, }) with _$ImagePack { - static String emojiMarkdown(String shortcode, String url) { - final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"'); - return '![:$shortcode:]($url "Emoji: $title")'; - } + /// The pack's avatar, or its first image if it has none. + Uri? get icon => avatar ?? content.images.values.firstOrNull?.url; + + String get imageCountLabel => content.images.length == 1 + ? "1 image" + : "${content.images.length} images"; + + /// The name of a pack without one, from its room's name and state key. + static String fallbackName(String roomName, String stateKey) => + stateKey.isEmpty ? roomName : "$roomName - $stateKey"; + + static String emojiMarkdown( + String shortcode, + String url, { + String? description, + }) => + '![:$shortcode:]($url "Emoji: ${switch (description) { + final description? when description.isNotEmpty => description, + _ => ":$shortcode:", + }.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")'; factory ImagePack.fromContent( ImagePackContent content, { - required String id, - required String fallbackName, + required String roomId, + required String roomName, + required String stateKey, }) => .new( - id: id, - name: content.pack?.displayName ?? fallbackName, + id: "$roomId/$stateKey", + name: content.pack?.displayName ?? fallbackName(roomName, stateKey), avatar: content.pack?.avatarUrl, emoticons: content.imagesFor(.emoticon), stickers: content.imagesFor(.sticker), + content: content, ); } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 720fa58..18eebbf 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -271,28 +271,30 @@ class const Composer( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( roomId: roomId, - onSelection: (value, {shortcode}) { - Navigator.of(context).pop(); - 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(); - }, + onSelection: + (value, {shortcode, description}) { + Navigator.of(context).pop(); + 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, + description: description, + ), + name: shortcode, + ); + } + node?.requestFocus(); + }, ), ), icon: Icon(Icons.emoji_emotions), diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index a360604..3ff0d55 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -3,6 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.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/content/image_pack.dart"; import "package:nexus/models/image_pack.dart"; class const EmojiOverlay( @@ -39,6 +40,10 @@ class const EmojiOverlay( ? ImagePack.emojiMarkdown( emoji.aliases.first, emoji.value, + description: switch (emoji.data) { + PackImage(:final body) => body, + _ => null, + }, ) : emoji.value, name: emoji.aliases.first, diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 4a64f38..fb834c9 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -16,7 +16,12 @@ import "package:nexus/models/content/image_pack.dart"; class const EmojiPicker({ required final String roomId, - required final FutureOr Function(String value, {String? shortcode}) + /// Has the [shortcode] and [description] of images from packs. + required final FutureOr Function( + String value, { + String? shortcode, + String? description, + }) onSelection, final bool allowFreeText = false, super.key, @@ -41,11 +46,14 @@ class const EmojiPicker({ return upstream.EmojiPicker( onSelection: (emoji) async { + final image = switch (emoji.data) { + final PackImage image => image, + _ => null, + }; await onSelection( emoji.value, - shortcode: emoji.data is PackImage - ? emoji.aliases.first - : null, + shortcode: image == null ? null : emoji.aliases.first, + description: image?.body, ); await ref diff --git a/lib/widgets/image_pack_avatar.dart b/lib/widgets/image_pack_avatar.dart new file mode 100644 index 0000000..aaebca2 --- /dev/null +++ b/lib/widgets/image_pack_avatar.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; + +class const ImagePackAvatar(final Uri? icon, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: 40, + child: switch (icon) { + final icon? => MxcEmoji(icon), + null => Icon(Icons.emoji_emotions), + }, + ); +} diff --git a/lib/widgets/settings/enable_image_packs_sheet.dart b/lib/widgets/settings/enable_image_packs_sheet.dart new file mode 100644 index 0000000..afd983b --- /dev/null +++ b/lib/widgets/settings/enable_image_packs_sheet.dart @@ -0,0 +1,118 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_image_packs.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/account_data.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses a room, then which of its image packs to use in every room. +class const EnableImagePacksSheet({ + required final ImagePackRooms enabled, + required final void Function(ImagePackRooms enabled) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(enabled); + final roomId = useState(null); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => ( + id: metadata.id, + name: metadata.name ?? metadata.id, + avatar: metadata.avatar, + ), + ) + .sortedBy((room) => room.name.toLowerCase()) + .toIList(), + ), + ); + + return SheetLayout( + title: Text("Add Image Packs"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave(chosen.value); + }, + child: Text("Save"), + ), + ], + children: [ + Text("Packs you add can be used in every room."), + DropdownMenu( + expandedInsets: .zero, + menuHeight: 300, + enableFilter: true, + requestFocusOnTap: true, + label: Text("Room"), + onSelected: (id) => roomId.value = id, + dropdownMenuEntries: rooms + .map( + (room) => DropdownMenuEntry( + value: room.id, + label: room.name, + leadingIcon: AvatarOrHash( + room.avatar, + room.name, + fallback: Icon(Icons.numbers), + ), + ), + ) + .toList(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: switch (roomId.value) { + final roomId? => + ref + .watch(RoomImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) => packs.isEmpty + ? Text("This room has no image packs") + : Column( + mainAxisSize: .min, + children: packs.entries + .map( + (entry) => CheckboxListTile( + secondary: ImagePackAvatar( + entry.value.icon, + ), + title: Text(entry.value.name), + subtitle: Text(entry.value.imageCountLabel), + value: chosen.value.contains( + roomId, + entry.key, + ), + onChanged: (value) => + chosen.value = chosen.value.withPack( + roomId, + entry.key, + enabled: value ?? false, + ), + ), + ) + .toList(), + ), + ), + null => const SizedBox(width: .infinity), + }, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_pack_sheet.dart b/lib/widgets/settings/image_pack_sheet.dart new file mode 100644 index 0000000..97f8599 --- /dev/null +++ b/lib/widgets/settings/image_pack_sheet.dart @@ -0,0 +1,250 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/settings/pack_image_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image pack, or only shows it if [onSave] is null. +class const ImagePackSheet({ + required final String title, + required final ImagePackContent content, + required final void Function(ImagePackContent content)? onSave, + final VoidCallback? onDeleted, + + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final editable = onSave != null; + final nameController = useTextEditingController( + text: content.pack?.displayName, + ); + final name = useValueListenable(nameController).text.trim(); + final avatar = useState(content.pack?.avatarUrl); + final usage = useState(content.pack?.usage ?? const ISet.empty()); + final images = useState(content.images); + final uploading = Saving.use(); + final packUsage = usage.value.isEmpty + ? ISet(ImageUsage.values) + : usage.value; + final valid = !uploading.active && name.isNotEmpty; + + Future upload( + void Function(String filename, PackImage image) onUploaded, + ) async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await uploading.run(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (!context.mounted) return; + + if (content case ImageMessageContent(:final url, :final info)) { + onUploaded(file.name, PackImage(url: url, info: info)); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + void editImage(String shortcode, PackImage image, {bool isNew = false}) => + context.showAdaptiveSheet( + (_) => PackImageSheet( + title: isNew + ? "Add Image" + : editable + ? "Edit Image" + : ":$shortcode:", + shortcode: shortcode, + image: image, + taken: isNew + ? images.value.keys.toISet() + : images.value.keys.toISet().remove(shortcode), + packUsage: packUsage, + onSave: editable + ? (newShortcode, newImage) => images.value = isNew + ? images.value.add(newShortcode, newImage) + // Replaced in place, to keep the images' order + : .fromEntries( + images.value.entries.map( + (entry) => entry.key == shortcode + ? MapEntry(newShortcode, newImage) + : entry, + ), + ) + : null, + onRemoved: editable && !isNew + ? () => images.value = images.value.remove(shortcode) + : null, + ), + ); + + Future delete() async { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Delete Pack"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + ], + children: [ + Text( + "Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.", + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + Navigator.of(context).pop(); + onDeleted?.call(); + } + + void save() { + Navigator.of(context).pop(); + onSave!( + content.copyWith( + images: images.value, + pack: (content.pack ?? const PackInfo()).copyWith( + displayName: name, + avatarUrl: avatar.value, + usage: usage.value, + ), + ), + ); + } + + final textTheme = Theme.of(context).textTheme; + + return SheetLayout( + title: Text(title), + actions: [ + if (onDeleted != null) + TextButton( + onPressed: uploading.active ? null : delete, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(editable ? "Cancel" : "Close"), + ), + if (editable) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: nameController, + enabled: editable, + autofocus: true, + decoration: .new(labelText: "Name"), + ), + ListTile( + enabled: editable && !uploading.active, + leading: Icon(Icons.image), + title: Text("Avatar"), + subtitle: Text("The pack's first image if not set."), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ImagePackAvatar( + avatar.value ?? images.value.values.firstOrNull?.url, + ), + if (avatar.value != null && editable) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: uploading.active + ? null + : () => avatar.value = null, + ), + ], + ), + onTap: () => + upload((_, image) => avatar.value = image.url).onError(showError), + ), + Text("Default usage", style: textTheme.titleSmall), + ImageUsageChips( + value: packUsage, + onChanged: editable + ? (value) => + usage.value = value.length == ImageUsage.values.length + ? const .empty() + : value + : null, + ), + Text("Images", style: textTheme.titleSmall), + if (images.value.isEmpty && !editable) Text("No images"), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...images.value.entries.map( + (entry) => Tooltip( + message: ":${entry.key}:", + child: InkWell( + borderRadius: .circular(8), + onTap: () => editImage(entry.key, entry.value), + child: Padding( + padding: .all(4), + child: SizedBox.square( + dimension: 56, + child: MxcEmoji(entry.value.url, label: entry.key), + ), + ), + ), + ), + ), + if (editable) + SizedBox.square( + dimension: 64, + child: uploading.active + ? Center(child: CircularProgressIndicator()) + : IconButton( + tooltip: "Add image", + icon: Icon(Icons.add_photo_alternate), + onPressed: () => upload( + (filename, image) => editImage( + filename + .split(".") + .first + .replaceAll(RegExp(r"[^\w-]"), ""), + image, + isNew: true, + ), + ).onError(showError), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_usage_chips.dart b/lib/widgets/settings/image_usage_chips.dart new file mode 100644 index 0000000..e9d1fbb --- /dev/null +++ b/lib/widgets/settings/image_usage_chips.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Chooses what images are used as, keeping at least one usage chosen. +class const ImageUsageChips({ + required final ISet value, + + /// Disables the chips if null. + required final void Function(ISet value)? onChanged, + super.key, +}) extends StatelessWidget { + static String nameOf(ImageUsage usage) => switch (usage) { + .emoticon => "Emoticons", + .sticker => "Stickers", + }; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 8, + children: ImageUsage.values + .map( + (usage) => FilterChip( + label: Text(nameOf(usage)), + selected: value.contains(usage), + onSelected: + onChanged == null || + (value.length == 1 && value.contains(usage)) + ? null + : (selected) => onChanged!( + selected ? value.add(usage) : value.remove(usage), + ), + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/settings/pack_image_sheet.dart b/lib/widgets/settings/pack_image_sheet.dart new file mode 100644 index 0000000..9ef70d6 --- /dev/null +++ b/lib/widgets/settings/pack_image_sheet.dart @@ -0,0 +1,134 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image in a pack, or only shows it if [onSave] is null. +class const PackImageSheet({ + required final String title, + required final String shortcode, + required final PackImage image, + + /// The other images' shortcodes, which this one can't also have. + required final ISet taken, + + /// The pack's default usage, which images have unless they set their own. + required final ISet packUsage, + required final void Function(String shortcode, PackImage image)? onSave, + final VoidCallback? onRemoved, + super.key, +}) extends HookWidget { + static final shortcodePattern = RegExp(r"^[\w-]+$"); + + @override + Widget build(BuildContext context) { + final shortcodeController = useTextEditingController(text: shortcode); + final bodyController = useTextEditingController(text: image.body); + final usage = useState(image.usage); + final newShortcode = useValueListenable(shortcodeController).text.trim(); + final error = taken.contains(newShortcode) + ? "Another image has this shortcode" + : newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode) + ? "Only letters, numbers, - and _ are allowed" + : null; + final valid = newShortcode.isNotEmpty && error == null; + + void save() { + final body = bodyController.text.trim(); + Navigator.of(context).pop(); + onSave!( + newShortcode, + image.copyWith(body: body.isEmpty ? null : body, usage: usage.value), + ); + } + + return SheetLayout( + title: Text(title), + actions: [ + if (onRemoved case final onRemoved?) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRemoved(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(onSave == null ? "Close" : "Cancel"), + ), + if (onSave != null) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + Center( + child: SizedBox.square( + dimension: 128, + child: MxcEmoji(image.url, label: shortcode), + ), + ), + TextField( + controller: shortcodeController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Shortcode", + prefixText: ":", + suffixText: ":", + errorText: error, + ), + ), + TextField( + controller: bodyController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Description", + hintText: "Read by screen readers", + ), + ), + Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + SwitchListTile( + title: Text("Custom usage"), + subtitle: Text( + usage.value.isEmpty + ? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})" + : "Used as chosen below, instead of the pack's default", + ), + value: usage.value.isNotEmpty, + onChanged: onSave == null + ? null + : (custom) => + usage.value = custom ? packUsage : const .empty(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usage.value.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + padding: .only(top: 8), + child: ImageUsageChips( + value: usage.value, + onChanged: onSave == null + ? null + : (value) => usage.value = value, + ), + ), + ), + ], + ), + ], + ); + } +}