From c982e065aff36871a056bbfdc98bd804761dea84 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 16 Jul 2026 18:07:36 -0300 Subject: [PATCH] Load profile pics from CDN pubkey path before kind 0 Try cdn.zapstore.dev/p/{pubkey}.webp first (iconsm when tiny), then fall back to the kind-0 picture URL on error. --- lib/utils/image_url.dart | 12 ++++ lib/widgets/common/profile_avatar.dart | 80 +++++++++++++++++++++----- lib/widgets/profile_header.dart | 2 +- test/utils/image_url_test.dart | 26 +++++++++ 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lib/utils/image_url.dart b/lib/utils/image_url.dart index f23812c..3faf4fd 100644 --- a/lib/utils/image_url.dart +++ b/lib/utils/image_url.dart @@ -21,3 +21,15 @@ String? getCdnImageUrl(String? imageUrl, CdnImageVariant variant) { params['class'] = variant.name; return uri.replace(queryParameters: params).toString(); } + +/// Zapstore CDN profile picture for a hex pubkey (256px by default). +/// +/// For tiny avatars, pass [tiny] to request `class=iconsm`. +String? getProfileCdnUrl(String? pubkey, {bool tiny = false}) { + if (pubkey == null || pubkey.isEmpty) return null; + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(pubkey)) return null; + + final url = 'https://$_cdnHost/p/${pubkey.toLowerCase()}.webp'; + if (!tiny) return url; + return getCdnImageUrl(url, CdnImageVariant.iconsm); +} diff --git a/lib/widgets/common/profile_avatar.dart b/lib/widgets/common/profile_avatar.dart index 4748134..4ece4dc 100644 --- a/lib/widgets/common/profile_avatar.dart +++ b/lib/widgets/common/profile_avatar.dart @@ -5,13 +5,14 @@ import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; import '../../theme.dart'; -class ProfileAvatar extends StatelessWidget { +class ProfileAvatar extends StatefulWidget { final Profile? profile; final double radius; final List? borderColors; /// When provided, shows a signed-in placeholder (account_circle) /// instead of the generic person icon used for signed-out state. + /// Also used for the CDN profile picture path when [profile] is null. final String? pubkey; const ProfileAvatar({ @@ -22,12 +23,53 @@ class ProfileAvatar extends StatelessWidget { this.pubkey, }); + @override + State createState() => _ProfileAvatarState(); +} + +class _ProfileAvatarState extends State { + /// When true, skip the CDN pubkey URL and use kind-0 picture instead. + bool _useKind0Fallback = false; + + bool get _tiny => widget.radius < 24; + + String? get _resolvedPubkey => widget.profile?.pubkey ?? widget.pubkey; + + String? get _cdnUrl => getProfileCdnUrl(_resolvedPubkey, tiny: _tiny); + + String? get _kind0Url => getCdnImageUrl( + sanitizeHttpUrl(widget.profile?.pictureUrl), + _tiny ? CdnImageVariant.iconsm : CdnImageVariant.icon, + ); + + String? get _imageUrl { + if (!_useKind0Fallback) { + final cdn = _cdnUrl; + if (cdn != null) return cdn; + } + return _kind0Url; + } + + @override + void didUpdateWidget(covariant ProfileAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + final pubkeyChanged = + (oldWidget.profile?.pubkey ?? oldWidget.pubkey) != _resolvedPubkey; + final pictureChanged = + oldWidget.profile?.pictureUrl != widget.profile?.pictureUrl; + final radiusCrossedTiny = + (oldWidget.radius < 24) != _tiny; + if (pubkeyChanged || pictureChanged || radiusCrossedTiny) { + _useKind0Fallback = false; + } + } + Widget _buildPlaceholder(BuildContext context) { // Use different icon for signed-in vs signed-out state - final isSignedIn = pubkey != null; + final isSignedIn = widget.pubkey != null || widget.profile != null; return Container( - width: radius * 2, - height: radius * 2, + width: widget.radius * 2, + height: widget.radius * 2, color: const Color(0xFF1A1A1A), child: Center( child: Icon( @@ -35,40 +77,50 @@ class ProfileAvatar extends StatelessWidget { color: isSignedIn ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6) : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), - size: isSignedIn ? radius * 1.8 : radius * 0.9, + size: isSignedIn ? widget.radius * 1.8 : widget.radius * 0.9, ), ), ); } + void _onImageError(String failedUrl) { + if (_useKind0Fallback) return; + final kind0 = _kind0Url; + if (kind0 == null || kind0 == failedUrl) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _useKind0Fallback = true); + }); + } + @override Widget build(BuildContext context) { - final pictureUrl = getCdnImageUrl( - sanitizeHttpUrl(profile?.pictureUrl), - radius < 24 ? CdnImageVariant.iconsm : CdnImageVariant.icon, - ); + final pictureUrl = _imageUrl; Widget avatar = ClipOval( child: pictureUrl != null ? CachedNetworkImage( + key: ValueKey(pictureUrl), imageUrl: pictureUrl, fit: BoxFit.cover, - width: radius * 2, - height: radius * 2, + width: widget.radius * 2, + height: widget.radius * 2, fadeInDuration: const Duration(milliseconds: 500), fadeOutDuration: const Duration(milliseconds: 200), placeholder: (context, url) => _buildPlaceholder(context), - errorWidget: (context, url, error) => _buildPlaceholder(context), + errorWidget: (context, url, error) { + _onImageError(url); + return _buildPlaceholder(context); + }, ) : _buildPlaceholder(context), ); - if (borderColors != null) { + if (widget.borderColors != null) { return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( shape: BoxShape.circle, - gradient: LinearGradient(colors: borderColors!), + gradient: LinearGradient(colors: widget.borderColors!), ), child: Container( decoration: BoxDecoration( diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart index 31594c5..f914747 100644 --- a/lib/widgets/profile_header.dart +++ b/lib/widgets/profile_header.dart @@ -32,7 +32,7 @@ class ProfileHeader extends StatelessWidget { child: Column( children: [ // Profile avatar (handles null profile with fallback) - ProfileAvatar(profile: profile, radius: radius), + ProfileAvatar(profile: profile, pubkey: pubkey, radius: radius), const SizedBox(height: 16), // Display name with loading state diff --git a/test/utils/image_url_test.dart b/test/utils/image_url_test.dart index ad9db8c..9212984 100644 --- a/test/utils/image_url_test.dart +++ b/test/utils/image_url_test.dart @@ -43,4 +43,30 @@ void main() { expect(getCdnImageUrl('', CdnImageVariant.icon), ''); }); }); + + group('getProfileCdnUrl', () { + const pubkey = + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c1eb8ce57016d6d'; + + test('builds the 256px CDN profile URL', () { + expect( + getProfileCdnUrl(pubkey), + 'https://cdn.zapstore.dev/p/$pubkey.webp', + ); + }); + + test('adds iconsm for tiny avatars', () { + expect( + getProfileCdnUrl(pubkey, tiny: true), + 'https://cdn.zapstore.dev/p/$pubkey.webp?class=iconsm', + ); + }); + + test('rejects non-hex and empty pubkeys', () { + expect(getProfileCdnUrl(null), isNull); + expect(getProfileCdnUrl(''), isNull); + expect(getProfileCdnUrl('npub1abc'), isNull); + expect(getProfileCdnUrl('not-a-key'), isNull); + }); + }); }