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,182 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
// ── Windows constants ────────────────────────────────────────────────────────
|
||||
|
||||
const _CF_DIB = 8;
|
||||
const _CF_DIBV4 = 20;
|
||||
|
||||
// ── Native function typedefs ─────────────────────────────────────────────────
|
||||
|
||||
typedef _OpenClipboardNative = Int32 Function(IntPtr hwnd);
|
||||
typedef _OpenClipboard = int Function(int hwnd);
|
||||
|
||||
typedef _CloseClipboardNative = Int32 Function();
|
||||
typedef _CloseClipboard = int Function();
|
||||
|
||||
typedef _IsFormatAvailableNative = Int32 Function(Uint32 format);
|
||||
typedef _IsFormatAvailable = int Function(int format);
|
||||
|
||||
typedef _GetClipboardDataNative = IntPtr Function(Uint32 format);
|
||||
typedef _GetClipboardData = int Function(int format);
|
||||
|
||||
typedef _GlobalSizeNative = IntPtr Function(IntPtr hMem);
|
||||
typedef _GlobalSize = int Function(int hMem);
|
||||
|
||||
typedef _GlobalLockNative = Pointer<Uint8> Function(IntPtr hMem);
|
||||
typedef _GlobalLock = Pointer<Uint8> Function(int hMem);
|
||||
|
||||
typedef _GlobalUnlockNative = Int32 Function(IntPtr hMem);
|
||||
typedef _GlobalUnlock = int Function(int hMem);
|
||||
|
||||
typedef _RegisterFormatNative = Uint32 Function(Pointer<Utf16> name);
|
||||
typedef _RegisterFormat = int Function(Pointer<Utf16> name);
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns image bytes (PNG) from the Windows clipboard, or null if there is
|
||||
/// no image or the platform is not Windows.
|
||||
///
|
||||
/// Checks the following formats in order:
|
||||
/// 1. "PNG" – custom registered format used by browsers / screenshot tools
|
||||
/// 2. CF_DIBV4 / CF_DIB – Device-Independent Bitmap (Win+Shift+S, etc.)
|
||||
Uint8List? getClipboardImageBytes() {
|
||||
if (!Platform.isWindows) return null;
|
||||
try {
|
||||
return _readClipboardImage();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
Uint8List? _readClipboardImage() {
|
||||
final user32 = DynamicLibrary.open('user32.dll');
|
||||
final kernel32 = DynamicLibrary.open('kernel32.dll');
|
||||
|
||||
final openClipboard =
|
||||
user32.lookupFunction<_OpenClipboardNative, _OpenClipboard>('OpenClipboard');
|
||||
final closeClipboard =
|
||||
user32.lookupFunction<_CloseClipboardNative, _CloseClipboard>('CloseClipboard');
|
||||
final isFormatAvailable =
|
||||
user32.lookupFunction<_IsFormatAvailableNative, _IsFormatAvailable>(
|
||||
'IsClipboardFormatAvailable');
|
||||
final getClipboardData =
|
||||
user32.lookupFunction<_GetClipboardDataNative, _GetClipboardData>('GetClipboardData');
|
||||
final registerFormat =
|
||||
user32.lookupFunction<_RegisterFormatNative, _RegisterFormat>(
|
||||
'RegisterClipboardFormatW');
|
||||
final globalSize =
|
||||
kernel32.lookupFunction<_GlobalSizeNative, _GlobalSize>('GlobalSize');
|
||||
final globalLock =
|
||||
kernel32.lookupFunction<_GlobalLockNative, _GlobalLock>('GlobalLock');
|
||||
final globalUnlock =
|
||||
kernel32.lookupFunction<_GlobalUnlockNative, _GlobalUnlock>('GlobalUnlock');
|
||||
|
||||
// Register custom "PNG" format (Chrome, Edge, Firefox, Snipping Tool export)
|
||||
final pngNamePtr = 'PNG'.toNativeUtf16();
|
||||
final pngFormat = registerFormat(pngNamePtr);
|
||||
malloc.free(pngNamePtr);
|
||||
|
||||
if (openClipboard(0) == 0) return null;
|
||||
|
||||
try {
|
||||
// 1. Try custom PNG format — bytes are ready-to-use PNG
|
||||
if (pngFormat != 0 && isFormatAvailable(pngFormat) != 0) {
|
||||
final bytes = _readGlobalBytes(
|
||||
getClipboardData(pngFormat), globalSize, globalLock, globalUnlock);
|
||||
if (bytes != null && bytes.isNotEmpty) return bytes;
|
||||
}
|
||||
|
||||
// 2. Try CF_DIBV4 then CF_DIB — convert DIB to PNG via the image package
|
||||
for (final fmt in [_CF_DIBV4, _CF_DIB]) {
|
||||
if (isFormatAvailable(fmt) != 0) {
|
||||
final dibBytes = _readGlobalBytes(
|
||||
getClipboardData(fmt), globalSize, globalLock, globalUnlock);
|
||||
if (dibBytes != null && dibBytes.isNotEmpty) {
|
||||
final png = _dibToPng(dibBytes);
|
||||
if (png != null) return png;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
closeClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the bytes pointed to by a global memory handle.
|
||||
Uint8List? _readGlobalBytes(
|
||||
int handle,
|
||||
_GlobalSize globalSize,
|
||||
_GlobalLock globalLock,
|
||||
_GlobalUnlock globalUnlock,
|
||||
) {
|
||||
if (handle == 0) return null;
|
||||
final size = globalSize(handle);
|
||||
if (size == 0) return null;
|
||||
final ptr = globalLock(handle);
|
||||
if (ptr.address == 0) return null;
|
||||
try {
|
||||
return Uint8List.fromList(ptr.asTypedList(size));
|
||||
} finally {
|
||||
globalUnlock(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a CF_DIB / CF_DIBV4 block to PNG bytes using the `image` package.
|
||||
///
|
||||
/// A DIB is essentially a BMP without the 14-byte file header. We prepend the
|
||||
/// header and let the `image` package decode it.
|
||||
Uint8List? _dibToPng(Uint8List dib) {
|
||||
if (dib.length < 40) return null; // minimum BITMAPINFOHEADER size
|
||||
|
||||
final bd = ByteData.sublistView(dib);
|
||||
|
||||
final headerSize = bd.getUint32(0, Endian.little); // biSize
|
||||
final bitCount = bd.getUint16(14, Endian.little); // biBitCount
|
||||
final compression = bd.getUint32(16, Endian.little); // biCompression
|
||||
final clrUsed = bd.getUint32(32, Endian.little); // biClrUsed
|
||||
|
||||
// Calculate color table size (in bytes, each entry = 4 bytes RGBQUAD)
|
||||
int colorTableEntries;
|
||||
if (bitCount <= 8) {
|
||||
colorTableEntries = clrUsed != 0 ? clrUsed : (1 << bitCount);
|
||||
} else if (compression == 3 /* BI_BITFIELDS */ || compression == 6 /* BI_ALPHABITFIELDS */) {
|
||||
colorTableEntries = 0;
|
||||
} else {
|
||||
colorTableEntries = clrUsed;
|
||||
}
|
||||
final colorTableBytes = colorTableEntries * 4;
|
||||
|
||||
// Offset to pixel data from start of FILE (= 14 file header bytes + DIB)
|
||||
final pixelOffset = 14 + headerSize + colorTableBytes;
|
||||
|
||||
// Build the 14-byte BITMAPFILEHEADER
|
||||
final fileSize = 14 + dib.length;
|
||||
final fileHeader = Uint8List(14);
|
||||
final fh = ByteData.sublistView(fileHeader);
|
||||
fileHeader[0] = 0x42; // 'B'
|
||||
fileHeader[1] = 0x4D; // 'M'
|
||||
fh.setUint32(2, fileSize, Endian.little);
|
||||
fh.setUint32(6, 0, Endian.little);
|
||||
fh.setUint32(10, pixelOffset, Endian.little);
|
||||
|
||||
final bmp = Uint8List(fileHeader.length + dib.length);
|
||||
bmp.setRange(0, fileHeader.length, fileHeader);
|
||||
bmp.setRange(fileHeader.length, bmp.length, dib);
|
||||
|
||||
try {
|
||||
final decoded = img.decodeBmp(bmp);
|
||||
if (decoded == null) return null;
|
||||
return Uint8List.fromList(img.encodePng(decoded));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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