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; const UserAvatar({ required this.id, this.isGroup = false, this.size = 20, 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); } // Cap keeps the heap bounded for power-users in Talk; TTL ensures // server-side avatar updates become visible within a session without // requiring an app restart. LinkedHashMap insertion-order plus a remove // on hit gives us LRU eviction. const int _kAvatarCacheMax = 256; const Duration _kAvatarCacheTtl = Duration(minutes: 30); // Resolved payloads are cached so re-mounts render synchronously; in-flight // requests are deduped so concurrent mounts share one HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {}; _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(); } @override void didUpdateWidget(UserAvatar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size) { _attach(); } } String _url() { final host = EndpointData().nextcloud().full(); if (widget.isGroup) { return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar'; } return 'https://$host/avatar/${widget.id}/${widget.size}'; } 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('