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
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user