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.
This commit is contained in:
franzap
2026-07-16 18:07:36 -03:00
parent 6691464e82
commit c982e065af
4 changed files with 105 additions and 15 deletions
+12
View File
@@ -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);
}
+66 -14
View File
@@ -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<Color>? 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<ProfileAvatar> createState() => _ProfileAvatarState();
}
class _ProfileAvatarState extends State<ProfileAvatar> {
/// 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(
+1 -1
View File
@@ -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
+26
View File
@@ -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);
});
});
}