import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; const _stickerServerUrl = 'https://stickers.steggi-matrix.work'; class GifFavoriteService { // ── GIF Favorites cache ───────────────────────────────────────────────── static final List> _cache = []; static bool _loaded = false; // ── Sticker Favorites cache ────────────────────────────────────────────── static final List> _stickerCache = []; static bool _stickerLoaded = false; static Future>> load() async { if (_loaded) return _cache; try { final res = await http.get(Uri.parse('$_stickerServerUrl/gif-favorites')); final data = jsonDecode(res.body) as List; _cache ..clear() ..addAll(data.cast>()); _loaded = true; } catch (_) {} return _cache; } static List> get cache => List.unmodifiable(_cache); /// True if the given https:// URL is already a favorite. static bool isFavorite(String url) => _cache.any((f) => f['url'] == url); /// True if a favorite was created from this mxc:// source URL. static bool isFavoriteByMxc(String mxcUrl) => _cache.any((f) => f['mxc_source'] == mxcUrl); /// Upload raw GIF/WebP bytes to the Pi server. /// Returns the public https:// URL on success, null on failure. static Future uploadToPi(Uint8List bytes, String mimeType) async { try { final res = await http.post( Uri.parse('$_stickerServerUrl/gif-file-upload'), headers: {'Content-Type': mimeType}, body: bytes, ); if (res.statusCode == 200) { final data = jsonDecode(res.body); return data['url'] as String?; } } catch (_) {} return null; } /// Toggle a plain external GIF (Giphy etc.). static Future toggle( String url, String previewUrl, String title, ) async { final removing = isFavorite(url); if (removing) { _cache.removeWhere((f) => f['url'] == url); } else { _cache.add({'url': url, 'preview_url': previewUrl, 'title': title}); } try { await http.post( Uri.parse('$_stickerServerUrl/gif-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'action': removing ? 'remove' : 'add', 'url': url, 'preview_url': previewUrl, 'title': title, }), ); } catch (_) { // Revert on error if (removing) { _cache.add({'url': url, 'preview_url': previewUrl, 'title': title}); } else { _cache.removeWhere((f) => f['url'] == url); } } } /// Add a favorite that was uploaded from an mxc:// source. /// [url] is the Pi-hosted https:// URL; [mxcSource] is the original mxc:// URL. static Future addWithMxcSource( String url, String previewUrl, String title, String mxcSource, ) async { final entry = { 'url': url, 'preview_url': previewUrl, 'title': title, 'mxc_source': mxcSource, }; _cache.add(entry); try { await http.post( Uri.parse('$_stickerServerUrl/gif-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'action': 'add', 'url': url, 'preview_url': previewUrl, 'title': title, 'mxc_source': mxcSource, }), ); } catch (_) { _cache.removeWhere((f) => f['mxc_source'] == mxcSource); } } /// Remove a favorite that was originally added from an mxc:// source. static Future removeByMxc(String mxcSource) async { _cache.removeWhere((f) => f['mxc_source'] == mxcSource); try { await http.post( Uri.parse('$_stickerServerUrl/gif-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'action': 'remove', 'url': '', 'mxc_source': mxcSource, }), ); } catch (_) {} } /// Extracts the URL logic from a matrix event to easily toggle favoriting globally. static Future toggleEventFavorites(dynamic event) async { await load(); String rawUrl = ''; final url = event.content['url'] as String?; if (url != null && url.isNotEmpty) { rawUrl = url; } else { final fileObj = event.content['file']; if (fileObj is Map) { final fileUrl = fileObj['url']; if (fileUrl is String && fileUrl.isNotEmpty) rawUrl = fileUrl; } } if (rawUrl.isEmpty) return false; final u = Uri.tryParse(rawUrl); final isExternal = u != null && (u.scheme == 'https' || u.scheme == 'http'); final title = event.content['body'] as String? ?? 'Sticker'; final isFav = isExternal ? isFavorite(rawUrl) : isFavoriteByMxc(rawUrl); if (isExternal) { await toggle(rawUrl, rawUrl, title); return true; } else if (rawUrl.startsWith('mxc://')) { if (isFav) { await removeByMxc(rawUrl); return true; } else { try { final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp'; final piUrl = await uploadToPi(matrixFile.bytes, mimeType); if (piUrl != null) { await addWithMxcSource(piUrl, piUrl, title, rawUrl); return true; } } catch (e) { return false; } } } return false; } // ── Sticker Favorites ──────────────────────────────────────────────────── /// Load sticker favorites from the server into the sticker cache. static Future>> loadStickers() async { if (_stickerLoaded) return _stickerCache; try { final res = await http.get(Uri.parse('$_stickerServerUrl/sticker-favorites')); final data = jsonDecode(res.body) as List; _stickerCache ..clear() ..addAll(data.cast>()); _stickerLoaded = true; } catch (_) {} return _stickerCache; } static List> get stickerCache => List.unmodifiable(_stickerCache); /// All user-created stickers (saved from images). Shown in "Meine Sticker". static List> get userStickerItems => List.unmodifiable( _stickerCache.where((s) => s['user_sticker'] == true).toList(), ); /// Explicitly favorited stickers. Includes: /// - User stickers with favorited == true /// - Pack sticker favorites (entries without user_sticker == true) static List> get stickerFavoritesItems => List.unmodifiable( _stickerCache.where((s) { if (s['user_sticker'] == true) return s['favorited'] == true; return true; }).toList(), ); /// Remove a sticker from the local cache immediately (without a server round-trip). /// Call this after successfully posting a remove action to the server. static void removeSticker(String mxcUrl) { _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); } /// True if this mxc:// URL is in the effective favorites list. static bool isStickerFavorite(String mxcUrl) => stickerFavoritesItems.any((s) => s['mxc_source'] == mxcUrl); /// Toggle the favorited flag on a user sticker. Returns true on success. static Future setUserStickerFavorited(String mxcSource, bool favorited) async { final idx = _stickerCache.indexWhere((s) => s['mxc_source'] == mxcSource); if (idx == -1) return false; _stickerCache[idx]['favorited'] = favorited; try { await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'action': 'set_favorited', 'mxc_source': mxcSource, 'favorited': favorited, }), ); return true; } catch (_) { _stickerCache[idx]['favorited'] = !favorited; return false; } } /// Add or remove any image/sticker event from sticker favorites. /// Handles both unencrypted (content['url']) and encrypted (content['file']['url']) events. /// For encrypted events, re-uploads the decrypted bytes to Matrix so the /// stored mxc:// URL can be sent as a sticker without decryption keys. static Future addImageAsStickerFavorite(dynamic event) async { await loadStickers(); // Resolve original mxc URL (used as deduplication key) String rawMxc = event.content['url'] as String? ?? ''; final bool isEncrypted = event.content['file'] != null; if (rawMxc.isEmpty && isEncrypted) { final fileObj = event.content['file']; if (fileObj is Map) rawMxc = fileObj['url'] as String? ?? ''; } if (rawMxc.isEmpty) return false; final title = event.content['body'] as String? ?? 'Sticker'; // Check if ALREADY in cache (by mxc_source), regardless of favorited status. // This prevents duplicates when user taps "Als Sticker speichern" on an already-saved sticker. final existingIdx = _stickerCache.indexWhere((s) => s['mxc_source'] == rawMxc); if (existingIdx != -1) { // Toggle: remove the existing user sticker final backup = Map.from(_stickerCache[existingIdx]); _stickerCache.removeAt(existingIdx); try { await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}), ); return true; } catch (_) { _stickerCache.insert(existingIdx, backup); return false; } } else { try { final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); final mimeType = (event.infoMap['mimetype'] as String?) ?? 'image/png'; // For encrypted events the original mxc:// points to ciphertext. // Re-upload the decrypted bytes to get a plain, sendable mxc:// URL. String stickerMxc; if (isEncrypted) { final dynamic uploadedUri = await event.room.client.uploadContent( matrixFile.bytes, filename: 'sticker', contentType: mimeType, ); stickerMxc = uploadedUri.toString(); } else { stickerMxc = rawMxc; } // Also upload to Pi server for the picker preview thumbnail final piUrl = await uploadToPi(matrixFile.bytes, mimeType); if (piUrl == null) return false; final entry = { 'mxc_source': rawMxc, // deduplication key (original encrypted or plain mxc) 'mxc': stickerMxc, // sendable unencrypted mxc:// URL 'url': piUrl, // Pi preview URL shown in the picker 'title': title, 'mimetype': mimeType, 'user_sticker': true, // created from an image, shown in "Meine Sticker" 'favorited': false, // not in favorites until explicitly hearted }; _stickerCache.add(entry); await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'add', ...entry}), ); return true; } catch (_) { _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); return false; } } } /// Add or remove a pack sticker from favorites using its already-accessible /// thumbnail URL — no download or re-upload needed. static Future togglePackStickerFavorite({ required String mxcUrl, required String thumbUrl, required String title, String mimetype = 'image/webp', }) async { await loadStickers(); final isFav = isStickerFavorite(mxcUrl); if (isFav) { _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); try { await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'remove', 'mxc_source': mxcUrl}), ); return true; } catch (_) { _stickerCache.add({'mxc_source': mxcUrl, 'mxc': mxcUrl, 'url': thumbUrl, 'title': title}); return false; } } else { final entry = { 'mxc_source': mxcUrl, 'mxc': mxcUrl, 'url': thumbUrl, 'title': title, 'mimetype': mimetype, 'user_sticker': false, 'favorited': false, }; _stickerCache.add(entry); try { await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'add', ...entry}), ); return true; } catch (_) { _stickerCache.removeWhere((s) => s['mxc_source'] == mxcUrl); return false; } } } /// Toggle a sticker event into/out of the sticker favorites. /// For user-created stickers, only the favorited flag is toggled. /// For pack stickers, the sticker is added/removed from the favorites list. static Future toggleStickerFavorite(dynamic event) async { await loadStickers(); final rawMxc = event.content['url'] as String? ?? ''; if (rawMxc.isEmpty) return false; final title = event.content['body'] as String? ?? 'Sticker'; // If it's a user-created sticker, toggle its favorited flag only final userIdx = _stickerCache.indexWhere( (s) => s['mxc_source'] == rawMxc && s['user_sticker'] == true, ); if (userIdx != -1) { final currentFav = _stickerCache[userIdx]['favorited'] == true; return setUserStickerFavorited(rawMxc, !currentFav); } final isFav = isStickerFavorite(rawMxc); if (isFav) { _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); try { await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'remove', 'mxc_source': rawMxc}), ); return true; } catch (_) { _stickerCache.add({'mxc_source': rawMxc, 'title': title}); return false; } } else { // Upload thumbnail bytes to Pi for preview try { final matrixFile = await event.downloadAndDecryptAttachment(getThumbnail: false); final mimeType = event.infoMap['mimetype'] as String? ?? 'image/webp'; final piUrl = await uploadToPi(matrixFile.bytes, mimeType); if (piUrl == null) return false; final entry = { 'mxc_source': rawMxc, 'mxc': rawMxc, 'url': piUrl, 'title': title, 'mimetype': mimeType, 'user_sticker': false, 'favorited': false, }; _stickerCache.add(entry); await http.post( Uri.parse('$_stickerServerUrl/sticker-favorites'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'action': 'add', ...entry}), ); return true; } catch (_) { _stickerCache.removeWhere((s) => s['mxc_source'] == rawMxc); return false; } } } }