import 'dart:collection'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import '../model/account_data.dart'; import '../model/endpoint_data.dart'; class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; /// Server-side pixel size requested for user avatars. `null` lets the /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical /// device pixel ratios. Group avatars ignore this (Spreed serves one /// fixed-size image per token). final int? requestSize; const UserAvatar({ required this.id, this.isGroup = false, this.size = 20, this.requestSize, super.key, }); @override State createState() => _UserAvatarState(); } class _AvatarPayload { final Uint8List bytes; final bool isSvg; _AvatarPayload(this.bytes, this.isSvg); } class _AvatarCacheEntry { final _AvatarPayload? payload; final DateTime fetchedAt; _AvatarCacheEntry(this.payload, this.fetchedAt); } // LRU via LinkedHashMap insertion order + remove-on-hit. TTL so // server-side avatar updates become visible within a session. const int _kAvatarCacheMax = 256; const Duration _kAvatarCacheTtl = Duration(minutes: 30); // Pending map dedups concurrent mounts onto a single HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {}; // Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve. // Clearing the cache map alone only affects future mounts — a UserAvatar // elsewhere on screen (chat list, chat header) holds its bytes in State and // would keep showing the stale image until rebuilt. Each state listens here // and re-attaches: invalidated urls miss the cache and re-fetch, the rest // hit the cache and cost nothing. final ValueNotifier _avatarCacheGeneration = ValueNotifier(0); String avatarUrl({required String id, required bool isGroup, int size = 512}) { final host = EndpointData().nextcloud().full(); if (isGroup) { return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; } return 'https://$host/avatar/$id/$size'; } /// Drops cached avatar bytes so the next mount re-fetches from the server. /// Call after the app uploaded or removed an avatar — without this the /// 30-min TTL would mask the change for the rest of the session. /// /// With [id]+[isGroup], invalidates every cached size for that subject /// (user avatars cache per-size URL). Without arguments, clears everything. /// Pending fetches are also discarded so a stale in-flight response can't /// repopulate the cache. void invalidateAvatarCache({String? id, bool? isGroup}) { if (id == null) { _resolvedAvatars.clear(); _pendingAvatars.clear(); } else if (isGroup == true) { final url = avatarUrl(id: id, isGroup: true); _resolvedAvatars.remove(url); _pendingAvatars.remove(url); } else { // User avatars include the rendered size in the URL — drop every variant. final host = EndpointData().nextcloud().full(); final prefix = 'https://$host/avatar/$id/'; _resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix)); _pendingAvatars.removeWhere((url, _) => url.startsWith(prefix)); } _avatarCacheGeneration.value++; } _AvatarCacheEntry? _readAvatarCache(String url) { final entry = _resolvedAvatars.remove(url); if (entry == null) return null; if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) { return null; } // Re-insert at the tail so it counts as most-recently-used. _resolvedAvatars[url] = entry; return entry; } void _writeAvatarCache(String url, _AvatarPayload? payload) { _resolvedAvatars.remove(url); _resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now()); while (_resolvedAvatars.length > _kAvatarCacheMax) { _resolvedAvatars.remove(_resolvedAvatars.keys.first); } } class _UserAvatarState extends State { _AvatarPayload? _payload; @override void initState() { super.initState(); _attach(); _avatarCacheGeneration.addListener(_onCacheInvalidated); } @override void dispose() { _avatarCacheGeneration.removeListener(_onCacheInvalidated); super.dispose(); } // Re-resolve when the cache generation changes. Cache hit → no network and // the visible bytes stay; cache miss (our url was invalidated) → re-fetch. void _onCacheInvalidated() { if (!mounted) return; setState(_attach); } @override void didUpdateWidget(UserAvatar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size || oldWidget.requestSize != widget.requestSize) { _attach(); } } int _resolvedRequestSize() => widget.requestSize ?? (widget.size * 4).clamp(64, 1024); String _url() => avatarUrl( id: widget.id, isGroup: widget.isGroup, size: _resolvedRequestSize(), ); void _attach() { final url = _url(); final cached = _readAvatarCache(url); if (cached != null) { _payload = cached.payload; return; } _payload = null; final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url)); pending.then((p) { _writeAvatarCache(url, p); _pendingAvatars.remove(url); if (!mounted || _url() != url) return; setState(() => _payload = p); }); } Future<_AvatarPayload?> _fetch(String url) async { try { final response = await http.get( Uri.parse(url), headers: { 'Authorization': AccountData().getBasicAuthHeader(), 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ); if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null; final contentType = response.headers['content-type']?.toLowerCase() ?? ''; final bytes = response.bodyBytes; final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); return _AvatarPayload(bytes, isSvg); } catch (_) { return null; } } static bool _looksLikeSvg(Uint8List bytes) { final head = utf8 .decode( bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), allowMalformed: true, ) .trimLeft(); return head.startsWith('