iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: square gallery thumbs, portrait story thumbs, resize-cached at display size

CachedThumbnailView now uses a Nuke resize processor so the decoded
cache stores 44pt bitmaps instead of full-res images. Prefetcher
uses matching resized requests. Preview thumb assets simulate the
bsky avatar CDN preset (1000×1000 center-crop).

+78 -28
+3
Grain/Preview Content/Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:497e1eebfc3f1d4b25e54105fb5e2df99449d5366eef4c4675aa142ce31c7e9b 3 + size 249351
+3
Grain/Preview Content/Mt_Herschel,_Antarctica,_Jan_2006_thumb.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:6f6622fb8b023e4a9b7aa6610a0119a214b8e8b3068d451fc303f8fc3e6102c1 3 + size 203630
+3
Grain/Preview Content/Portland_Japanese_Garden_maple_thumb.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:05a6f2139954ccc9d51252af3a3d2caca5b1cddc3fc3bd2e16600185b99af9a9 3 + size 550383
+13 -13
Grain/Utilities/PreviewData.swift
··· 403 403 author: profile2, 404 404 galleryUri: gallery1.uri, 405 405 galleryTitle: gallery1.title, 406 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple") 406 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 407 407 ), 408 408 GrainNotification( 409 409 uri: "at://did:plc:prevuser6/social.grain.notification/n1b", ··· 412 412 author: profile6, 413 413 galleryUri: gallery1.uri, 414 414 galleryTitle: gallery1.title, 415 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple") 415 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 416 416 ), 417 417 GrainNotification( 418 418 uri: "at://did:plc:prevuser7/social.grain.notification/n1c", ··· 421 421 author: profile7, 422 422 galleryUri: gallery1.uri, 423 423 galleryTitle: gallery1.title, 424 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple") 424 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 425 425 ), 426 426 GrainNotification( 427 427 uri: "at://did:plc:prevuser8/social.grain.notification/n1d", ··· 430 430 author: profile8, 431 431 galleryUri: gallery1.uri, 432 432 galleryTitle: gallery1.title, 433 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple") 433 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 434 434 ), 435 435 // — Single gallery comment 436 436 GrainNotification( ··· 440 440 author: profile3, 441 441 galleryUri: gallery1.uri, 442 442 galleryTitle: gallery1.title, 443 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple"), 443 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb"), 444 444 commentText: "The light in the third frame is unreal. What film stock?" 445 445 ), 446 446 // — Follow group: 3 users followed within 48h → "Kai and 2 others followed you" ··· 469 469 createdAt: ago(1 * day), 470 470 author: profile3, 471 471 storyUri: stories[0].uri, 472 - storyThumb: bundleImageURL("Portland_Japanese_Garden_maple") 472 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb") 473 473 ), 474 474 GrainNotification( 475 475 uri: "at://did:plc:prevuser5/social.grain.notification/n6b", ··· 477 477 createdAt: ago(1 * day + 2 * hour), 478 478 author: profile5, 479 479 storyUri: stories[0].uri, 480 - storyThumb: bundleImageURL("Portland_Japanese_Garden_maple") 480 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb") 481 481 ), 482 482 // — Single gallery favorite (different gallery, no group) 483 483 GrainNotification( ··· 487 487 author: profile2, 488 488 galleryUri: gallery2.uri, 489 489 galleryTitle: gallery2.title, 490 - galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon") 490 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 491 491 ), 492 492 // — Single comment mention 493 493 GrainNotification( ··· 497 497 author: profile5, 498 498 galleryUri: gallery1.uri, 499 499 galleryTitle: gallery1.title, 500 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple"), 500 + galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 501 501 commentText: "Tagged you in a comment: @yuki.grain.social beautiful work!" 502 502 ), 503 503 // — Single gallery mention ··· 508 508 author: profile4, 509 509 galleryUri: gallery3.uri, 510 510 galleryTitle: gallery3.title, 511 - galleryThumb: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006") 511 + galleryThumb: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006_thumb") 512 512 ), 513 513 // — Single story comment 514 514 GrainNotification( ··· 517 517 createdAt: ago(3 * day + 5 * hour), 518 518 author: profile6, 519 519 storyUri: stories[0].uri, 520 - storyThumb: bundleImageURL("Portland_Japanese_Garden_maple"), 520 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 521 521 commentText: "Love the autumn colors here" 522 522 ), 523 523 // — Single reply ··· 528 528 author: profile7, 529 529 galleryUri: gallery1.uri, 530 530 galleryTitle: gallery1.title, 531 - galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple"), 531 + galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 532 532 commentText: "Totally agree, Portra is unmatched for skin tones" 533 533 ), 534 534 // — Single story favorite (different story, won't group with the pair above) ··· 538 538 createdAt: ago(4 * day + 3 * hour), 539 539 author: profile8, 540 540 storyUri: stories[1].uri, 541 - storyThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon") 541 + storyThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 542 542 ), 543 543 // — Single follow (>48h from the group, won't merge) 544 544 GrainNotification(
+24 -5
Grain/ViewModels/NotificationsViewModel.swift
··· 79 79 } 80 80 } 81 81 82 + private static let thumbSize: CGFloat = 44 83 + private static let thumbSquareSize: CGSize = { 84 + let px = thumbSize * UIScreen.main.scale 85 + return CGSize(width: px, height: px) 86 + }() 87 + 88 + private static let thumbPortraitSize: CGSize = { 89 + let scale = UIScreen.main.scale 90 + return CGSize(width: thumbSize * scale, height: thumbSize * 4 / 3 * scale) 91 + }() 92 + 82 93 private func prefetchImages(_ notifs: [GrainNotification]) { 83 - var urlStrings = notifs.compactMap(\.author.avatar) 84 - urlStrings += notifs.compactMap(\.galleryThumb) 85 - urlStrings += notifs.compactMap(\.storyThumb) 86 - let urls = urlStrings.compactMap { URL(string: $0) } 87 - prefetcher.startPrefetching(with: urls) 94 + let avatarURLs = notifs.compactMap(\.author.avatar).compactMap { URL(string: $0) } 95 + var requests = avatarURLs.map { ImageRequest(url: $0) } 96 + 97 + for notif in notifs { 98 + if let thumb = notif.galleryThumb, let url = URL(string: thumb) { 99 + requests.append(ImageRequest(url: url, processors: [.resize(size: Self.thumbSquareSize, contentMode: .aspectFill)])) 100 + } 101 + if let thumb = notif.storyThumb, let url = URL(string: thumb) { 102 + requests.append(ImageRequest(url: url, processors: [.resize(size: Self.thumbPortraitSize, contentMode: .aspectFill)])) 103 + } 104 + } 105 + 106 + prefetcher.startPrefetching(with: requests) 88 107 } 89 108 90 109 func fetchUnseenCount(auth: AuthContext? = nil) async {
+32 -10
Grain/Views/Notifications/NotificationsView.swift
··· 220 220 group.notification.galleryThumb ?? group.notification.storyThumb 221 221 } 222 222 223 + private var isStoryThumb: Bool { 224 + group.notification.galleryThumb == nil && group.notification.storyThumb != nil 225 + } 226 + 223 227 var body: some View { 224 228 HStack(alignment: .top, spacing: 10) { 225 229 ReasonIcon(reason: group.notification.reasonType) ··· 279 283 .overlay(alignment: .trailing) { 280 284 if let thumb { 281 285 Button { onSubjectTap?() } label: { 282 - CachedThumbnailView(url: thumb, height: 44) 286 + CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb) 283 287 } 284 288 .buttonStyle(.plain) 285 289 } ··· 307 311 notification.galleryThumb ?? notification.storyThumb 308 312 } 309 313 314 + private var isStoryThumb: Bool { 315 + notification.galleryThumb == nil && notification.storyThumb != nil 316 + } 317 + 310 318 var body: some View { 311 319 HStack(alignment: .top, spacing: 10) { 312 320 ReasonIcon(reason: notification.reasonType) ··· 344 352 .overlay(alignment: .trailing) { 345 353 if let thumb { 346 354 Button { onSubjectTap?() } label: { 347 - CachedThumbnailView(url: thumb, height: 44) 355 + CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb) 348 356 } 349 357 .buttonStyle(.plain) 350 358 } ··· 493 501 494 502 private struct CachedThumbnailView: View { 495 503 let url: String 496 - let height: CGFloat 504 + let size: CGFloat 505 + var portrait: Bool = false 506 + 507 + private var width: CGFloat { 508 + size 509 + } 510 + 511 + private var height: CGFloat { 512 + portrait ? size * 4 / 3 : size 513 + } 497 514 498 515 @State private var asyncImage: UIImage? 499 516 ··· 501 518 URL(string: url) 502 519 } 503 520 521 + private var thumbRequest: ImageRequest? { 522 + guard let imageURL else { return nil } 523 + let scale = UIScreen.main.scale 524 + let targetSize = CGSize(width: width * scale, height: height * scale) 525 + return ImageRequest(url: imageURL, processors: [.resize(size: targetSize, contentMode: .aspectFill)]) 526 + } 527 + 504 528 private var resolvedImage: UIImage? { 505 - if let imageURL, 506 - let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image 529 + if let request = thumbRequest, 530 + let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image 507 531 { 508 532 return cached 509 533 } ··· 515 539 if let image = resolvedImage { 516 540 Image(uiImage: image) 517 541 .resizable() 518 - .aspectRatio(contentMode: .fit) 542 + .aspectRatio(contentMode: .fill) 519 543 } else { 520 544 Rectangle().fill(.quaternary) 521 - .aspectRatio(1, contentMode: .fit) 522 545 } 523 546 } 524 - .frame(height: height) 547 + .frame(width: width, height: height) 525 548 .clipShape(.rect(cornerRadius: 6)) 526 549 .onAppear { loadIfNeeded() } 527 550 } 528 551 529 552 private func loadIfNeeded() { 530 - guard let imageURL else { return } 531 - let request = ImageRequest(url: imageURL) 553 + guard let request = thumbRequest else { return } 532 554 if ImagePipeline.shared.cache.cachedImage(for: request) != nil { return } 533 555 guard asyncImage == nil else { return } 534 556 Task {