simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+37 -32
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -76,13 +77,16 @@ final Map<String, Future<AvatarPayload?>> _pendingAvatars = {};
final ValueNotifier<int> _avatarCacheGeneration = ValueNotifier<int>(0);
String avatarUrl({required String id, required bool isGroup, int size = 512}) {
final host = EndpointData().nextcloud().full();
if (isGroup) {
final host = EndpointData().nextcloud().full();
return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar';
}
return 'https://$host/avatar/$id/$size';
return '${_userAvatarUrlPrefix(id)}$size';
}
String _userAvatarUrlPrefix(String id) =>
'https://${EndpointData().nextcloud().full()}/avatar/$id/';
/// Resolves an avatar through the shared caches: in-memory first, then disk,
/// then the network — the fetch being deduped across every concurrent caller
/// and written back to both caches.
@@ -109,26 +113,32 @@ Future<AvatarPayload?> loadAvatarPayload({
return payload;
}
final payload = await _fetchDeduped(url);
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload);
return payload;
return _fetchDeduped(url, id: id, isGroup: isGroup, size: size);
}
/// Shares one in-flight request per URL across all callers.
Future<AvatarPayload?> _fetchDeduped(String url) =>
_pendingAvatars.putIfAbsent(url, () {
final future = _fetchAvatarPayload(url);
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
/// Shares one in-flight request per URL across all callers; the result is
/// committed to the caches once, not once per waiting caller.
Future<AvatarPayload?> _fetchDeduped(
String url, {
required String id,
required bool isGroup,
required int size,
}) => _pendingAvatars.putIfAbsent(url, () {
final future = _fetchAvatarPayload(url).then((payload) {
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload);
return payload;
});
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
/// Persists a resolved result to the in-memory and disk caches.
void _commitAvatar(
@@ -189,10 +199,7 @@ AvatarPayload _payloadFromBytes(Uint8List bytes) =>
bool _looksLikeSvg(Uint8List bytes) {
final head = utf8
.decode(
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
allowMalformed: true,
)
.decode(bytes.sublist(0, min(bytes.length, 256)), allowMalformed: true)
.trimLeft();
return head.startsWith('<?xml') || head.startsWith('<svg');
}
@@ -220,8 +227,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
unawaited(PushAvatarStore.evict(id));
} else {
// User avatars include the rendered size in the URL — drop every variant.
final host = EndpointData().nextcloud().full();
final prefix = 'https://$host/avatar/$id/';
final prefix = _userAvatarUrlPrefix(id);
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
@@ -285,14 +291,15 @@ class _UserAvatarState extends State<UserAvatar> {
int _resolvedRequestSize() =>
widget.requestSize ?? (widget.size * 4).clamp(64, 1024);
String _url() => avatarUrl(
String _url([int? size]) => avatarUrl(
id: widget.id,
isGroup: widget.isGroup,
size: _resolvedRequestSize(),
size: size ?? _resolvedRequestSize(),
);
void _attach() {
final url = _url();
final size = _resolvedRequestSize();
final url = _url(size);
final cached = _readAvatarCache(url);
if (cached != null) {
_payload = cached.payload;
@@ -302,7 +309,6 @@ class _UserAvatarState extends State<UserAvatar> {
// the widget may have been recycled onto a different id by then.
final id = widget.id;
final isGroup = widget.isGroup;
final size = _resolvedRequestSize();
// Persistent disk cache: on a warm session (cache directory already known)
// this hits synchronously, so a cold app start paints the last-known
@@ -348,14 +354,13 @@ class _UserAvatarState extends State<UserAvatar> {
AvatarPayload? fresh;
try {
fresh = await _fetchDeduped(url);
fresh = await _fetchDeduped(url, id: id, isGroup: isGroup, size: size);
} on Object {
// Transient failure (offline, 5xx). Keep showing the cached picture; the
// next mount retries. Deliberately no null-cache so we don't mask it.
return;
}
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh);
if (!mounted || _url() != url) return;
if (fresh == null) {
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.