diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 95d05f2c7..d4fbe86b5 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -14,6 +14,21 @@ "min": {} } }, + "notAnImage": "Not an image file.", + "remove": "Replace", + "importNow": "Import now", + "importEmojis": "Import Emojis", + "importFromZipFile": "Import from .zip file", + "importZipFile": "Import .zip file", + "exportEmotePack": "Export Emote pack as .zip", + "replace": "Replace", + "savedEmotePack": "Saved emote pack to {path}!", + "@savedEmotePack": { + "type": "text", + "placeholders": { + "path": {} + } + }, "about": "About", "@about": { "type": "text", diff --git a/lib/pages/settings_emotes/import_archive_dialog.dart b/lib/pages/settings_emotes/import_archive_dialog.dart new file mode 100644 index 000000000..0e3343a94 --- /dev/null +++ b/lib/pages/settings_emotes/import_archive_dialog.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:archive/archive.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/settings_emotes/settings_emotes.dart'; +import 'package:fluffychat/utils/client_manager.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ImportEmoteArchiveDialog extends StatefulWidget { + final EmotesSettingsController controller; + final Archive archive; + + const ImportEmoteArchiveDialog({ + super.key, + required this.controller, + required this.archive, + }); + + @override + State createState() => + _ImportEmoteArchiveDialogState(); +} + +class _ImportEmoteArchiveDialogState extends State { + Map _importMap = {}; + + bool _loading = false; + + @override + void initState() { + _importFileMap(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(L10n.of(context)!.importEmojis), + content: _loading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 8, + spacing: 8, + children: _importMap.entries + .map( + (e) => _EmojiImportPreview( + key: ValueKey(e.key.name), + entry: e, + onNameChanged: (name) => _importMap[e.key] = name, + onRemove: () => + setState(() => _importMap.remove(e.key)), + ), + ) + .toList(), + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : Navigator.of(context).pop, + child: Text(L10n.of(context)!.cancel), + ), + TextButton( + onPressed: _loading + ? null + : _importMap.isNotEmpty + ? _addEmotePack + : null, + child: Text(L10n.of(context)!.importNow), + ), + ], + ); + } + + void _importFileMap() { + _importMap = Map.fromEntries( + widget.archive.files + .where((e) => e.isFile) + .map( + (e) => MapEntry(e, e.name.emoteNameFromPath), + ) + .sorted( + (a, b) => a.value.compareTo(b.value), + ), + ); + } + + Future _addEmotePack() async { + setState(() { + _loading = true; + }); + final imports = _importMap; + final successfulUploads = {}; + + // check for duplicates first + + final skipKeys = []; + + for (final entry in imports.entries) { + final imageCode = entry.value; + + if (widget.controller.pack!.images.containsKey(imageCode)) { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final result = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context)!.emoteExists, + message: imageCode, + cancelLabel: L10n.of(context)!.replace, + okLabel: L10n.of(context)!.skip, + ); + completer.complete(result); + }); + + final result = await completer.future; + if (result == OkCancelResult.ok) { + skipKeys.add(entry.key); + } + } + } + + for (final key in skipKeys) { + imports.remove(key); + } + + for (final entry in imports.entries) { + final file = entry.key; + final imageCode = entry.value; + + // try { + var mxcFile = MatrixImageFile( + bytes: file.content, + name: file.name, + ); + try { + mxcFile = (await mxcFile.generateThumbnail( + nativeImplementations: ClientManager.nativeImplementations, + ))!; + } catch (e, s) { + Logs().w('Unable to create thumbnail', e, s); + } + final uri = await Matrix.of(context).client.uploadContent( + mxcFile.bytes, + filename: mxcFile.name, + contentType: mxcFile.mimeType, + ); + + final info = { + ...mxcFile.info, + }; + + // normalize width / height to 256, required for stickers + if (info['w'] is int && info['h'] is int) { + final ratio = info['w'] / info['h']; + if (info['w'] > info['h']) { + info['w'] = 256; + info['h'] = (256.0 / ratio).round(); + } else { + info['h'] = 256; + info['w'] = (ratio * 256.0).round(); + } + } + widget.controller.pack!.images[imageCode] = + ImagePackImageContent.fromJson({ + 'url': uri.toString(), + 'info': info, + }); + successfulUploads.add(file.name); + /*} catch (e) { + + Logs().d('Could not upload emote $imageCode'); + }*/ + } + + await widget.controller.save(context); + _importMap.removeWhere( + (key, value) => successfulUploads.contains(key.name), + ); + + _loading = false; + + // in case we have unhandled / duplicated emotes left, don't pop + if (mounted) setState(() {}); + if (_importMap.isEmpty) { + WidgetsBinding.instance + .addPostFrameCallback((_) => Navigator.of(context).pop()); + } + } +} + +class _EmojiImportPreview extends StatefulWidget { + final MapEntry entry; + final ValueChanged onNameChanged; + final VoidCallback onRemove; + + const _EmojiImportPreview({ + Key? key, + required this.entry, + required this.onNameChanged, + required this.onRemove, + }) : super(key: key); + + @override + State<_EmojiImportPreview> createState() => _EmojiImportPreviewState(); +} + +class _EmojiImportPreviewState extends State<_EmojiImportPreview> { + final hasErrorNotifier = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + // TODO: support Lottie here as well ... + final controller = TextEditingController(text: widget.entry.value); + + return Stack( + alignment: Alignment.topRight, + children: [ + IconButton( + onPressed: widget.onRemove, + icon: const Icon(Icons.remove_circle), + tooltip: L10n.of(context)!.remove, + ), + ValueListenableBuilder( + valueListenable: hasErrorNotifier, + builder: (context, hasError, child) { + if (hasError) return _ImageFileError(name: widget.entry.key.name); + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.memory( + widget.entry.key.content, + height: 64, + width: 64, + errorBuilder: (context, e, s) { + WidgetsBinding.instance + .addPostFrameCallback((_) => _setRenderError()); + + return _ImageFileError( + name: widget.entry.key.name, + ); + }, + ), + SizedBox( + width: 128, + child: TextField( + controller: controller, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^[-\w]+$')) + ], + autocorrect: false, + minLines: 1, + maxLines: 1, + decoration: InputDecoration( + hintText: L10n.of(context)!.emoteShortcode, + prefixText: ': ', + suffixText: ':', + border: const OutlineInputBorder(), + prefixStyle: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + suffixStyle: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + onChanged: widget.onNameChanged, + onSubmitted: widget.onNameChanged, + ), + ), + ], + ); + }, + ), + ], + ); + } + + _setRenderError() { + hasErrorNotifier.value = true; + widget.onRemove.call(); + } +} + +class _ImageFileError extends StatelessWidget { + final String name; + + const _ImageFileError({required this.name}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 64, + child: Tooltip( + message: name, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.error), + Text( + L10n.of(context)!.notAnImage, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + } +} + +extension on String { + /// normalizes a file path into its name only replacing any special character + /// [^-\w] with an underscore and removing the extension + /// + /// Used to compute emote name proposal based on file name + String get emoteNameFromPath { + // ... removing leading path + return split(RegExp(r'[/\\]')) + .last + // ... removing file extension + .split('.') + .first + // ... lowering + .toLowerCase() + // ... replacing unexpected characters + .replaceAll(RegExp(r'[^-\w]'), '_'); + } +} diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index e9384bc05..e699dcc4e 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -5,13 +8,19 @@ import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/utils/client_manager.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import '../../widgets/matrix.dart'; +import 'import_archive_dialog.dart'; import 'settings_emotes_view.dart'; +import 'package:archive/archive.dart' + if (dart.library.io) 'package:archive/archive_io.dart'; + class EmotesSettings extends StatefulWidget { const EmotesSettings({Key? key}) : super(key: key); @@ -21,8 +30,10 @@ class EmotesSettings extends StatefulWidget { class EmotesSettingsController extends State { String? get roomId => VRouter.of(context).pathParameters['roomid']; + Room? get room => roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null; + String? get stateKey => VRouter.of(context).pathParameters['state_key']; bool showSave = false; @@ -44,6 +55,7 @@ class EmotesSettingsController extends State { } ImagePackContent? _pack; + ImagePackContent? get pack { if (_pack != null) { return _pack; @@ -52,7 +64,7 @@ class EmotesSettingsController extends State { return _pack; } - Future _save(BuildContext context) async { + Future save(BuildContext context) async { if (readonly) { return; } @@ -161,7 +173,7 @@ class EmotesSettingsController extends State { room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes')); void saveAction() async { - await _save(context); + await save(context); setState(() { showSave = false; }); @@ -198,7 +210,7 @@ class EmotesSettingsController extends State { return; } pack!.images[imageCode] = newImageController.value!; - await _save(context); + await save(context); setState(() { newImageCodeController.text = ''; newImageController.value = null; @@ -262,4 +274,77 @@ class EmotesSettingsController extends State { Widget build(BuildContext context) { return EmotesSettingsView(this); } + + Future importEmojiZip() async { + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: [ + 'zip', + // TODO: add further encoders + ], + // TODO: migrate to stream, currently brrrr because of `archive_io`. + withData: true, + ); + + if (result == null) return null; + + final buffer = InputStream(result.files.single.bytes); + + final archive = ZipDecoder().decodeBuffer(buffer); + + return archive; + }, + ); + + final archive = result.result; + if (archive == null) return; + + await showDialog( + context: context, + builder: (context) => ImportEmoteArchiveDialog( + controller: this, + archive: archive, + ), + ); + setState(() {}); + } + + Future exportAsZip() async { + final client = Matrix.of(context).client; + + await showFutureLoadingDialog( + context: context, + future: () async { + final pack = _getPack(); + final archive = Archive(); + for (final entry in pack.images.entries) { + final emote = entry.value; + final name = entry.key; + final url = emote.url.getDownloadLink(client); + final response = await get(url); + + archive.addFile( + ArchiveFile( + name, + response.bodyBytes.length, + response.bodyBytes, + ), + ); + } + final fileName = + '${pack.pack.displayName ?? client.userID?.localpart ?? 'emotes'}.zip'; + final output = ZipEncoder().encode(archive); + + if (output == null) return; + + MatrixFile( + name: fileName, + bytes: Uint8List.fromList(output), + ).save(context); + }, + ); + } } diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 8901143ef..64e8c3e39 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -10,6 +10,8 @@ import 'package:fluffychat/widgets/mxc_image.dart'; import '../../widgets/matrix.dart'; import 'settings_emotes.dart'; +enum PopupMenuEmojiActions { import, export } + class EmotesSettingsView extends StatelessWidget { final EmotesSettingsController controller; @@ -23,6 +25,31 @@ class EmotesSettingsView extends StatelessWidget { appBar: AppBar( leading: const BackButton(), title: Text(L10n.of(context)!.emoteSettings), + actions: [ + PopupMenuButton( + onSelected: (value) { + switch (value) { + case PopupMenuEmojiActions.export: + controller.exportAsZip(); + break; + case PopupMenuEmojiActions.import: + controller.importEmojiZip(); + break; + } + }, + enabled: !controller.readonly, + itemBuilder: (context) => [ + PopupMenuItem( + value: PopupMenuEmojiActions.import, + child: Text(L10n.of(context)!.importFromZipFile), + ), + PopupMenuItem( + value: PopupMenuEmojiActions.export, + child: Text(L10n.of(context)!.exportEmotePack), + ), + ], + ), + ], ), floatingActionButton: controller.showSave ? FloatingActionButton( diff --git a/pubspec.lock b/pubspec.lock index 63249360f..5d0fba731 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.0.1" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" diff --git a/pubspec.yaml b/pubspec.yaml index 174542d4b..8506b954c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: adaptive_dialog: ^1.9.0-x-macos-beta.1 animations: ^2.0.7 + archive: ^3.3.7 badges: ^2.0.3 blurhash_dart: ^1.1.0 callkeep: ^0.3.2