import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; 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:go_router/go_router.dart'; import 'package:http/http.dart' hide Client; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/widgets/app_lock.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({super.key}); @override EmotesSettingsController createState() => EmotesSettingsController(); } class EmotesSettingsController extends State { String? get roomId => GoRouterState.of(context).pathParameters['roomid']; Room? get room => roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null; String? get stateKey => GoRouterState.of(context).pathParameters['state_key']; bool showSave = false; TextEditingController newImageCodeController = TextEditingController(); ValueNotifier newImageController = ValueNotifier(null); ImagePackContent _getPack() { final client = Matrix.of(context).client; final event = (room != null ? room!.getState('im.ponies.room_emotes', stateKey ?? '') : client.accountData['im.ponies.user_emotes']) ?? BasicEvent( type: 'm.dummy', content: {}, ); // make sure we work on a *copy* of the event return BasicEvent.fromJson(event.toJson()).parsedImagePackContent; } ImagePackContent? _pack; ImagePackContent? get pack { if (_pack != null) { return _pack; } _pack = _getPack(); return _pack; } Future save(BuildContext context) async { if (readonly) { return; } final client = Matrix.of(context).client; if (room != null) { await showFutureLoadingDialog( context: context, future: () => client.setRoomStateWithKey( room!.id, 'im.ponies.room_emotes', stateKey ?? '', pack!.toJson(), ), ); } else { await showFutureLoadingDialog( context: context, future: () => client.setAccountData( client.userID!, 'im.ponies.user_emotes', pack!.toJson(), ), ); } } Future setIsGloballyActive(bool active) async { if (room == null) { return; } final client = Matrix.of(context).client; final content = client.accountData['im.ponies.emote_rooms']?.content ?? {}; if (active) { if (content['rooms'] is! Map) { content['rooms'] = {}; } if (content['rooms'][room!.id] is! Map) { content['rooms'][room!.id] = {}; } if (content['rooms'][room!.id][stateKey ?? ''] is! Map) { content['rooms'][room!.id][stateKey ?? ''] = {}; } } else if (content['rooms'] is Map && content['rooms'][room!.id] is Map) { content['rooms'][room!.id].remove(stateKey ?? ''); } // and save await showFutureLoadingDialog( context: context, future: () => client.setAccountData( client.userID!, 'im.ponies.emote_rooms', content, ), ); setState(() {}); } void removeImageAction(String oldImageCode) => setState(() { pack!.images.remove(oldImageCode); showSave = true; }); void submitImageAction( String oldImageCode, String imageCode, ImagePackImageContent image, TextEditingController controller, ) { if (pack!.images.keys.any((k) => k == imageCode && k != oldImageCode)) { controller.text = oldImageCode; showOkAlertDialog( useRootNavigator: false, context: context, message: L10n.of(context)!.emoteExists, okLabel: L10n.of(context)!.ok, ); return; } if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) { controller.text = oldImageCode; showOkAlertDialog( useRootNavigator: false, context: context, message: L10n.of(context)!.emoteInvalid, okLabel: L10n.of(context)!.ok, ); return; } setState(() { pack!.images[imageCode] = image; pack!.images.remove(oldImageCode); showSave = true; }); } bool isGloballyActive(Client? client) => room != null && client!.accountData['im.ponies.emote_rooms']?.content .tryGetMap('rooms') ?.tryGetMap(room!.id) ?.tryGetMap(stateKey ?? '') != null; bool get readonly => room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes')); void saveAction() async { await save(context); setState(() { showSave = false; }); } void addImageAction() async { if (newImageCodeController.text.isEmpty || newImageController.value == null) { await showOkAlertDialog( useRootNavigator: false, context: context, message: L10n.of(context)!.emoteWarnNeedToPick, okLabel: L10n.of(context)!.ok, ); return; } final imageCode = newImageCodeController.text; if (pack!.images.containsKey(imageCode)) { await showOkAlertDialog( useRootNavigator: false, context: context, message: L10n.of(context)!.emoteExists, okLabel: L10n.of(context)!.ok, ); return; } if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) { await showOkAlertDialog( useRootNavigator: false, context: context, message: L10n.of(context)!.emoteInvalid, okLabel: L10n.of(context)!.ok, ); return; } pack!.images[imageCode] = newImageController.value!; await save(context); setState(() { newImageCodeController.text = ''; newImageController.value = null; showSave = false; }); } void imagePickerAction( ValueNotifier controller, ) async { final result = await AppLock.of(context).pauseWhile( FilePicker.platform.pickFiles( type: FileType.image, withData: true, ), ); final pickedFile = result?.files.firstOrNull; if (pickedFile == null) return; var file = MatrixImageFile( bytes: pickedFile.bytes!, name: pickedFile.name, ); try { file = (await file.generateThumbnail( nativeImplementations: ClientManager.nativeImplementations, ))!; } catch (e, s) { Logs().w('Unable to create thumbnail', e, s); } final uploadResp = await showFutureLoadingDialog( context: context, future: () => Matrix.of(context).client.uploadContent( file.bytes, filename: file.name, contentType: file.mimeType, ), ); if (uploadResp.error == null) { setState(() { final info = { ...file.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(); } } controller.value = ImagePackImageContent.fromJson({ 'url': uploadResp.result.toString(), 'info': info, }); }); } } @override Widget build(BuildContext context) { return EmotesSettingsView(this); } Future importEmojiZip() async { final result = await showFutureLoadingDialog( context: context, future: () async { final result = await AppLock.of(context).pauseWhile( 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, // breaks [Matrix.of] calls otherwise useRootNavigator: false, 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); }, ); } }