diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 95d05f2c..d4fbe86b 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 00000000..0e3343a9 --- /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 e9384bc0..0f593276 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -5,13 +9,21 @@ 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:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/utils/client_manager.dart'; +import 'package:fluffychat/utils/platform_infos.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 +33,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 +58,7 @@ class EmotesSettingsController extends State { } ImagePackContent? _pack; + ImagePackContent? get pack { if (_pack != null) { return _pack; @@ -52,7 +67,7 @@ class EmotesSettingsController extends State { return _pack; } - Future _save(BuildContext context) async { + Future save(BuildContext context) async { if (readonly) { return; } @@ -161,7 +176,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 +213,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 +277,99 @@ 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; + + if (kIsWeb || PlatformInfos.isMobile) { + await Share.shareXFiles( + [XFile(fileName, bytes: Uint8List.fromList(output))], + ); + } else { + String? savePath = await FilePicker.platform + .saveFile(fileName: fileName, allowedExtensions: ['zip']); + + if (savePath == null) { + // workaround for broken `xdg-desktop-portal-termfilechooser` + if (PlatformInfos.isLinux) { + final dir = await getDownloadsDirectory(); + if (dir == null) return; + savePath = dir.uri.resolve(fileName).toFilePath(); + } else { + return; + } + } + + final file = File(savePath); + await file.writeAsBytes(output); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context)!.savedEmotePack(savePath))), + ); + } + }, + ); + } } diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 8901143e..eb2a264b 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -33,6 +33,20 @@ class EmotesSettingsView extends StatelessWidget { body: MaxWidthBody( child: Column( children: [ + if (!controller.readonly) + Container( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: ListTile( + title: Text(L10n.of(context)!.importFromZipFile), + trailing: IconButton( + tooltip: L10n.of(context)!.importZipFile, + icon: const Icon(Icons.file_open), + onPressed: controller.importEmojiZip, + ), + ), + ), if (!controller.readonly) Container( padding: const EdgeInsets.symmetric( @@ -203,6 +217,11 @@ class EmotesSettingsView extends StatelessWidget { }, ), ), + const Divider(), + ListTile( + title: Text(L10n.of(context)!.exportEmotePack), + onTap: controller.exportAsZip, + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 63249360..5d0fba73 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 174542d4..8506b954 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