Files
pyramid/lib/utils/gif_favorite_service.dart
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

445 lines
15 KiB
Dart

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<Map<String, dynamic>> _cache = [];
static bool _loaded = false;
// ── Sticker Favorites cache ──────────────────────────────────────────────
static final List<Map<String, dynamic>> _stickerCache = [];
static bool _stickerLoaded = false;
static Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>());
_loaded = true;
} catch (_) {}
return _cache;
}
static List<Map<String, dynamic>> 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<String?> 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<void> 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<void> 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<void> 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<bool> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>());
_stickerLoaded = true;
} catch (_) {}
return _stickerCache;
}
static List<Map<String, dynamic>> get stickerCache => List.unmodifiable(_stickerCache);
/// All user-created stickers (saved from images). Shown in "Meine Sticker".
static List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<bool> 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<bool> 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<String, dynamic>.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<bool> 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<bool> 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;
}
}
}
}