Files
buzz/mobile/lib/shared/community/community_icon_cache.dart
klopez4212 d2da104d94 feat(mobile): add animated community switcher (#8076)
## Summary

Replace the community switch sheet with a two-column picker. Selection
enlarges the avatar, waits with the voice-note loader for destination
channel content and layout to settle, then follows a curved path into
the header.

Cache raster icons at a maximum 512-pixel size and accept only
resource-free emoji SVGs. Cancel timed-out or disposed downloads without
delaying content readiness. Animate Edit badges in place, and open Add
Community on the pairing welcome page. Community removal lives only in
the switcher’s Edit mode. Keep the flow shared across Android/iOS, with
native iOS controls and removal confirmation, and clear navigation
backgrounds at rest.

### Related issue

Related to #8070 (community icon refresh/caching).

### Testing

- 122 focused Flutter regression tests and seven native iOS tests passed
after merging the latest navigation changes from `main`. Earlier feature
build installed and launched on a physical iPhone.
- Full mobile pre-push checks passed; regression coverage includes
gradient rendering, rejected/oversized icons, bounded persisted raster
decoding, SVG resource rejection and cache migration, timeout/disposal
cancellation, disconnected destination channels, degraded unread-history
settlement, and switching while icons refresh. Full `just ci` stopped
during Rust checks for low disk space. Hosted CI remains required.
- [Grid and Edit
previews](https://github.com/block/buzz/pull/8076#issuecomment-5977838957).

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-04 14:11:39 +01:00

73 lines
2.5 KiB
Dart

import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/theme_provider.dart';
// v1 allowed unbounded rasters and v2 allowed arbitrary SVG resources.
// Rebuild this derived cache through the validated artwork pipeline.
const _storageKey = 'buzz.community-icons.v3';
const _maximumEntries = 32;
const _maximumStoredCharacters = 3 * 1024 * 1024;
/// Last downloaded public icon artwork, keyed by the relay information URL.
/// It intentionally survives active-community changes and app restarts.
final communityIconCacheProvider =
NotifierProvider<CommunityIconCache, Map<String, String>>(
CommunityIconCache.new,
);
/// A bounded, best-effort local cache; it never stores community credentials.
class CommunityIconCache extends Notifier<Map<String, String>> {
SharedPreferences? _prefs;
Future<void> _writes = Future.value();
@override
Map<String, String> build() {
try {
_prefs = ref.read(savedPrefsProvider);
final raw = _prefs?.getString(_storageKey);
if (raw == null || raw.length > _maximumStoredCharacters) return {};
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return {};
return _bounded({
for (final entry in decoded.entries)
if (entry.value is String &&
(entry.value as String).startsWith('data:image/'))
entry.key: entry.value as String,
});
} catch (_) {
// Artwork caching is optional when preferences are unavailable/corrupt.
return {};
}
}
/// Stores complete artwork, or clears an explicitly removed relay icon.
Future<void> remember(String relay, String? artwork) {
if (state[relay] == artwork) return _writes;
final next = {...state}..remove(relay);
if (artwork != null) next[relay] = artwork;
state = _bounded(next);
final encoded = jsonEncode(state);
_writes = _writes.then((_) async {
try {
await _prefs?.setString(_storageKey, encoded);
} catch (_) {
// Retain the in-memory copy even if persistence is unavailable.
}
});
return _writes;
}
Map<String, String> _bounded(Map<String, String> entries) {
var size = jsonEncode(entries).length;
while (entries.length > _maximumEntries ||
size > _maximumStoredCharacters) {
entries.remove(entries.keys.first);
size = jsonEncode(entries).length;
}
return Map.unmodifiable(entries);
}
}