25ed765a03
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
445 lines
15 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|
|
}
|