fix(mobile): resolve inline push profile avatars

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
Tom Brow
2026-08-24 14:35:42 -07:00
parent b89c13af1e
commit 39bf80b062
8 changed files with 354 additions and 140 deletions

View File

@@ -50,7 +50,10 @@ public protocol BuzzPushNotificationResolving {
/// Reads configured Buzz communities and resolves their newest unread event.
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
static let maximumPresentationResponseBytes = 128 * 1_024
// Verified profiles may carry bounded inline raster avatars. Keep the refresh
// response capped, but large enough to recover the sender name from those
// otherwise valid kind-0 events when the app cache has not been populated.
static let maximumPresentationResponseBytes = 256 * 1_024
private let session: URLSession
private let loadCommunitiesData: () -> Data?
@@ -213,13 +216,13 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
else { return false }
return cachedChannel.memberDigests?.count != memberCount
}()
let channelNeedsRefresh = channelID != nil && (
Self.isStale(
let channelNeedsRefresh =
channelID != nil
&& (Self.isStale(
cachedAt: cachedChannel?.cachedAt,
now: timestamp,
lifetime: presentationCacheLifetime
) || membershipNeedsRefresh
)
) || membershipNeedsRefresh)
let fallback = Self.makeResolution(
event: event,
community: community,
@@ -237,50 +240,58 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
refreshProfile: profileNeedsRefresh,
refreshChannel: channelNeedsRefresh
) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in
let profile = refreshedProfileEvent.flatMap {
guard BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedProfile?.eventCreatedAt,
existingID: cachedProfile?.eventID,
candidateCreatedAt: $0.createdAt,
candidateID: $0.id
) else { return nil }
return Self.ephemeralProfile(
event: $0,
communityID: community.id,
relayOrigin: relayOrigin ?? community.relayUrl,
cached: cachedProfile,
cachedAt: timestamp
)
} ?? cachedProfile
let profile =
refreshedProfileEvent.flatMap {
guard
BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedProfile?.eventCreatedAt,
existingID: cachedProfile?.eventID,
candidateCreatedAt: $0.createdAt,
candidateID: $0.id
)
else { return nil }
return Self.ephemeralProfile(
event: $0,
communityID: community.id,
relayOrigin: relayOrigin ?? community.relayUrl,
cached: cachedProfile,
cachedAt: timestamp
)
} ?? cachedProfile
let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in
guard BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedChannel?.eventCreatedAt,
existingID: cachedChannel?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
) else { return nil }
guard
BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedChannel?.eventCreatedAt,
existingID: cachedChannel?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { return nil }
return event
}
let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in
guard BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedChannel?.membershipEventCreatedAt,
existingID: cachedChannel?.membershipEventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
) else { return nil }
guard
BuzzPushPresentationCacheStore.shouldReplace(
existingCreatedAt: cachedChannel?.membershipEventCreatedAt,
existingID: cachedChannel?.membershipEventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { return nil }
return event
}
let channel = relayMetadataPubkey.flatMap {
Self.ephemeralChannel(
metadataEvent: newerChannelEvent,
membershipEvent: newerMembershipEvent,
cached: cachedChannel,
communityID: community.id,
relayOrigin: relayOrigin ?? community.relayUrl,
relayMetadataPubkey: $0,
cachedAt: timestamp
)
} ?? cachedChannel
let channel =
relayMetadataPubkey.flatMap {
Self.ephemeralChannel(
metadataEvent: newerChannelEvent,
membershipEvent: newerMembershipEvent,
cached: cachedChannel,
communityID: community.id,
relayOrigin: relayOrigin ?? community.relayUrl,
relayMetadataPubkey: $0,
cachedAt: timestamp
)
} ?? cachedChannel
completion(
Self.makeResolution(
event: event,
@@ -297,9 +308,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
community: PushLeaseCommunity,
refreshProfile: Bool,
refreshChannel: Bool,
completion: @escaping (
VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent?
) -> Void
completion:
@escaping (
VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent?
) -> Void
) {
guard let privateKey = loadPrivateKey(community.id),
let relayURL = community.relayURL,
@@ -338,14 +350,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 1
// A verified kind-0 profile may include a bounded inline raster avatar.
// Railway can take longer than one second to return that larger response,
// while three seconds remains a small fraction of the NSE execution budget.
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url,
method: "POST",
body: body,
privateKeyHex: privateKey
) else {
guard
let auth = try? NostrHTTPAuth.authorizationHeader(
url: url,
method: "POST",
body: body,
privateKeyHex: privateKey
)
else {
completion(nil, nil, nil)
return
}
@@ -364,23 +381,32 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
return
}
let verified = events.filter { $0.hasValidIDAndSignature() }
let profile = refreshProfile ? Self.newest(verified.filter {
$0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased()
}) : nil
let channel = refreshChannel ? channelID.flatMap { channelID in
Self.newest(verified.filter {
$0.kind == 39_000
&& $0.pubkey.lowercased() == relayMetadataPubkey
&& Self.tagValue("d", in: $0) == channelID
})
} : nil
let membership = refreshChannel ? channelID.flatMap { channelID in
Self.newest(verified.filter {
$0.kind == 39_002
&& $0.pubkey.lowercased() == relayMetadataPubkey
&& Self.tagValue("d", in: $0) == channelID
})
} : nil
let profile =
refreshProfile
? Self.newest(
verified.filter {
$0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased()
}) : nil
let channel =
refreshChannel
? channelID.flatMap { channelID in
Self.newest(
verified.filter {
$0.kind == 39_000
&& $0.pubkey.lowercased() == relayMetadataPubkey
&& Self.tagValue("d", in: $0) == channelID
})
} : nil
let membership =
refreshChannel
? channelID.flatMap { channelID in
Self.newest(
verified.filter {
$0.kind == 39_002
&& $0.pubkey.lowercased() == relayMetadataPubkey
&& Self.tagValue("d", in: $0) == channelID
})
} : nil
completion(profile, channel, membership)
}.resume()
}
@@ -390,12 +416,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
let event = newestMessage(events: events, community: community)
guard let event else { return nil }
guard let resolution = makeResolution(
event: event,
community: community,
profile: nil,
channel: nil
) else { return nil }
guard
let resolution = makeResolution(
event: event,
community: community,
profile: nil,
channel: nil
)
else { return nil }
return (resolution, event)
}

View File

@@ -164,6 +164,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
public static let maximumAvatarBytes = 64 * 1024
public static let maximumTotalAvatarBytes = 4 * 1024 * 1024
public static let maximumSnapshotBytes = 8 * 1024 * 1024
static let maximumProfileMetadataBytes = 256 * 1024
private let fileURL: URL
private let now: () -> Date
@@ -203,15 +204,18 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
&& $0.pubkey == pubkey
}
let existing = index.map { snapshot.profiles[$0] }
guard Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
) else { continue }
guard
Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG)
let preservedAvatar = existing?.pictureHash == metadata.pictureHash
let preservedAvatar =
existing?.pictureHash == metadata.pictureHash
? existing?.avatarPNG : nil
let entry = BuzzPushCachedProfile(
communityID: communityID,
@@ -234,15 +238,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
Self.enforceBounds(&snapshot)
try writeLocked(snapshot)
return Set(snapshot.profiles.compactMap { profile in
guard profile.communityID == communityID,
profile.relayOrigin == canonicalRelayOrigin,
acceptedEventIDs.contains(profile.eventID),
profile.pictureHash != nil,
profile.avatarPNG == nil
else { return nil }
return profile.eventID
})
return Set(
snapshot.profiles.compactMap { profile in
guard profile.communityID == communityID,
profile.relayOrigin == canonicalRelayOrigin,
acceptedEventIDs.contains(profile.eventID),
profile.pictureHash != nil,
profile.avatarPNG == nil
else { return nil }
return profile.eventID
})
}
/// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots.
@@ -279,12 +284,15 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
}
let existing = index.map { snapshot.channels[$0] }
let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey
guard !hasCurrentAuthority || Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
) else { continue }
guard
!hasCurrentAuthority
|| Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
let entry = BuzzPushCachedChannel(
communityID: communityID,
@@ -328,12 +336,14 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
})
else { continue }
let existing = snapshot.channels[index]
guard Self.shouldReplace(
existingCreatedAt: existing.membershipEventCreatedAt,
existingID: existing.membershipEventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
) else { continue }
guard
Self.shouldReplace(
existingCreatedAt: existing.membershipEventCreatedAt,
existingID: existing.membershipEventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
snapshot.channels[index] = BuzzPushCachedChannel(
communityID: existing.communityID,
@@ -379,11 +389,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
var snapshot = loadLocked()
var changed = false
for index in snapshot.profiles.indices where
for index in snapshot.profiles.indices
where
snapshot.profiles[index].communityID == communityID
&& snapshot.profiles[index].relayOrigin == canonicalRelayOrigin
&& snapshot.profiles[index].pictureHash == pictureHash
&& snapshot.profiles[index].avatarPNG != normalizedPNG
&& snapshot.profiles[index].relayOrigin == canonicalRelayOrigin
&& snapshot.profiles[index].pictureHash == pictureHash
&& snapshot.profiles[index].avatarPNG != normalizedPNG
{
let profile = snapshot.profiles[index]
snapshot.profiles[index] = BuzzPushCachedProfile(
@@ -478,11 +489,12 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
static func profileMetadata(
_ event: VerifiedNostrEvent
) -> (displayName: String?, pictureHash: String?) {
guard event.content.utf8.count <= 32 * 1024,
guard event.content.utf8.count <= maximumProfileMetadataBytes,
let data = event.content.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return (nil, nil) }
let displayName = normalizedDisplayName(object["display_name"] as? String)
let displayName =
normalizedDisplayName(object["display_name"] as? String)
?? normalizedDisplayName(object["name"] as? String)
let pictureHash = normalizedAvatarURL(object["picture"] as? String).map {
VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8)))
@@ -525,13 +537,16 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
exceededMemberBound = pubkeys.count > maximumMembersPerChannel
}
}
let digests = exceededMemberBound ? nil : pubkeys.map {
BuzzPushPresentationIdentity.channelMember(
communityID: communityID,
channelID: channelID,
pubkey: $0
)
}.sorted()
let digests =
exceededMemberBound
? nil
: pubkeys.map {
BuzzPushPresentationIdentity.channelMember(
communityID: communityID,
channelID: channelID,
pubkey: $0
)
}.sorted()
return (
exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count,
digests
@@ -541,7 +556,20 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
static func normalizedAvatarURL(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed.utf8.count <= 2_048,
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("data:image/") {
guard trimmed.utf8.count <= maximumProfileMetadataBytes,
let separator = trimmed.firstIndex(of: ","),
separator < trimmed.index(before: trimmed.endIndex)
else { return nil }
let metadata = trimmed[..<separator].lowercased()
guard metadata.hasSuffix(";base64"),
["data:image/png;base64", "data:image/jpeg;base64", "data:image/webp;base64"]
.contains(String(metadata))
else { return nil }
return trimmed
}
guard trimmed.utf8.count <= 2_048,
let components = URLComponents(string: trimmed),
components.user == nil, components.password == nil,
components.host?.isEmpty == false,

View File

@@ -142,7 +142,7 @@ extension BuzzPushNotificationResolverTests {
if URLProtocolStub.requests.count == 1 {
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
}
XCTAssertEqual(request.timeoutInterval, 1)
XCTAssertEqual(request.timeoutInterval, 3)
return Self.response(
request,
status: 200,

View File

@@ -278,7 +278,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
if URLProtocolStub.requests.count == 1 {
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
}
XCTAssertEqual(request.timeoutInterval, 1)
XCTAssertEqual(request.timeoutInterval, 3)
return Self.response(request, status: 503, data: Data())
}
@@ -608,6 +608,59 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(URLProtocolStub.requests.count, 2)
}
func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws {
let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey,
createdAt: Self.now,
kind: 9,
tags: [["h", Self.channelID]],
content: "Inline avatar profile"
)
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
let profileContent = try XCTUnwrap(
String(
data: JSONSerialization.data(withJSONObject: [
"display_name": "Fizz",
"picture": picture,
]),
encoding: .utf8
)
)
let profile = try Self.signedEvent(
privateKey: Self.profilePrivateKey,
createdAt: Self.now,
kind: 0,
content: profileContent
)
let presentationData = try JSONEncoder().encode([profile])
XCTAssertLessThan(
presentationData.count,
BuzzPushNotificationResolver.maximumPresentationResponseBytes
)
URLProtocolStub.handler = { request in
Self.response(
request,
status: 200,
data: URLProtocolStub.requests.count == 1
? try JSONEncoder().encode([message]) : presentationData
)
}
let result = try XCTUnwrap(
resolve(
makeResolver(
communitiesData: try snapshotData([community()]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
)
)
)
XCTAssertEqual(result.title, "Fizz")
XCTAssertNil(result.senderAvatarPNG)
XCTAssertEqual(result.body, "Inline avatar profile")
XCTAssertEqual(URLProtocolStub.requests.count, 2)
}
func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws {
URLProtocolStub.handler = { request in
XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query")

View File

@@ -23,7 +23,8 @@ struct BuzzPushPresentationCacheTests {
privateKey: profileKey,
createdAt: 1_700_000_000,
kind: 0,
content: #"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"#
content:
#"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"#
)
let needsAvatar = try store.updateProfiles(
@@ -83,6 +84,55 @@ struct BuzzPushPresentationCacheTests {
)
}
@Test("Verified inline raster profile retains its name and accepts a local thumbnail")
func verifiedInlineRasterProfileAndAvatar() throws {
let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let store = BuzzPushPresentationCacheStore(containerURL: directory)
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
let content = try #require(
String(
data: JSONSerialization.data(withJSONObject: [
"display_name": "Fizz",
"picture": picture,
]),
encoding: .utf8
)
)
let event = try signedEvent(privateKey: profileKey, kind: 0, content: content)
let needsAvatar = try store.updateProfiles(
communityID: "community-a",
relayOrigin: "https://relay.example",
updates: [BuzzPushProfileCacheUpdate(event: event)]
)
#expect(needsAvatar == Set([event.id]))
#expect(
try loadSnapshot(directory).profile(
communityID: "community-a",
relayOrigin: "https://relay.example",
pubkey: event.pubkey
)?.displayName == "Fizz"
)
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
#expect(
try store.updateAvatar(
communityID: "community-a",
relayOrigin: "https://relay.example",
sourceURL: picture,
avatarPNG: png
)
)
#expect(
try loadSnapshot(directory).profile(
communityID: "community-a",
relayOrigin: "https://relay.example",
pubkey: event.pubkey
)?.avatarPNG == png
)
}
@Test("Verified profile falls back from blank display_name to name")
func verifiedProfileNameFallback() throws {
let directory = try temporaryDirectory()
@@ -266,13 +316,14 @@ struct BuzzPushPresentationCacheTests {
#expect(cached.memberCount == 2)
#expect(cached.membershipEventID == newestMembership.id)
#expect(
cached.memberDigests == [firstMember, secondMember].map {
BuzzPushPresentationIdentity.channelMember(
communityID: "community-a",
channelID: "opaque-channel",
pubkey: $0
)
}.sorted()
cached.memberDigests
== [firstMember, secondMember].map {
BuzzPushPresentationIdentity.channelMember(
communityID: "community-a",
channelID: "opaque-channel",
pubkey: $0
)
}.sorted()
)
}
@@ -413,7 +464,9 @@ struct BuzzPushPresentationCacheTests {
#expect(totalDigests == BuzzPushPresentationCacheStore.maximumTotalMemberDigests)
#expect(decoded.channels.first { $0.channelID == "channel-0" }?.memberDigests == nil)
#expect(decoded.channels.first { $0.channelID == "channel-\(channelCount - 1)" }?.memberDigests != nil)
#expect(
decoded.channels.first { $0.channelID == "channel-\(channelCount - 1)" }?.memberDigests != nil
)
}
@Test("Oversized membership keeps provenance but omits an incomplete digest set")
@@ -431,9 +484,10 @@ struct BuzzPushPresentationCacheTests {
let membership = try signedEvent(
privateKey: relayKey,
kind: 39_002,
tags: [["d", "large-channel"]] + (0..<memberCount).map {
["p", String(format: "%064x", $0 + 1)]
}
tags: [["d", "large-channel"]]
+ (0..<memberCount).map {
["p", String(format: "%064x", $0 + 1)]
}
)
try store.updateChannels(
@@ -461,7 +515,8 @@ struct BuzzPushPresentationCacheTests {
@Test("Legacy cache decodes with channel membership unavailable")
func legacyCacheCompatibility() throws {
let legacy = try #require(
#"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"#.data(using: .utf8)
#"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"#
.data(using: .utf8)
)
let channel = try #require(BuzzPushPresentationCacheSnapshot.decode(legacy).channels.first)
@@ -582,7 +637,8 @@ struct BuzzPushPresentationCacheTests {
@Test("Encoded cache remains bounded with adversarial strings and maximum avatars")
func encodedCacheByteBound() throws {
let controlText = String(repeating: "\u{0001}", count: 1_024)
let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
let avatar =
Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
+ Data(
repeating: 0,
count: BuzzPushPresentationCacheStore.maximumAvatarBytes - 8

View File

@@ -173,7 +173,7 @@ Future<void> cacheBuzzPushAvatarFromLoadedBytes(
communityID.isEmpty ||
sourceBytes.isEmpty ||
sourceBytes.length > _maximumAvatarSourceBytes ||
!_isRemoteImageURL(sourceURL)) {
!isCacheablePushAvatarSource(sourceURL)) {
return;
}
final previous = _avatarEncodeTail;
@@ -209,7 +209,19 @@ Future<void> _invokeBestEffort(
}
}
bool _isRemoteImageURL(String value) {
@visibleForTesting
bool isCacheablePushAvatarSource(String value) {
final trimmed = value.trim();
if (trimmed.startsWith('data:image/')) {
try {
final data = UriData.parse(trimmed);
return data.mimeType.startsWith('image/') &&
data.mimeType != 'image/svg+xml' &&
data.contentAsBytes().isNotEmpty;
} on FormatException {
return false;
}
}
final uri = Uri.tryParse(value.trim());
return uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&

View File

@@ -74,6 +74,7 @@ class AvatarImageContent extends ConsumerStatefulWidget {
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
String? _scheduledPushAvatar;
@override
void didUpdateWidget(AvatarImageContent oldWidget) {
@@ -110,10 +111,11 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
_RasterDataAvatarSource(:final bytes) => _rasterImage(
communityID: communityID,
sourceURL: widget.imageUrl,
bytes: bytes,
fallback: centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
@@ -128,6 +130,31 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
null => centeredFallback,
};
}
Widget _rasterImage({
required String? communityID,
required String? sourceURL,
required Uint8List bytes,
required Widget fallback,
}) {
if (communityID != null && sourceURL != null) {
final identity = '$communityID\u0000$sourceURL';
if (_scheduledPushAvatar != identity) {
_scheduledPushAvatar = identity;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _scheduledPushAvatar != identity) return;
unawaited(
cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes),
);
});
}
}
return Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => fallback,
);
}
}
sealed class _AvatarSource {

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
import 'package:buzz/shared/push/push_presentation_cache.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -23,6 +24,15 @@ void main() {
),
);
test('accepts bounded raster data avatars for the push cache', () {
expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue);
expect(
isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='),
isFalse,
);
expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse);
});
testWidgets('renders raccoon percent-encoded SVG data avatar', (
tester,
) async {