mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user