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.

Merge pull request #17 from grainsocial/fix/notifications-and-warnings

Fix notifications UI and thumbnail rendering

authored by

Chad Miller and committed by
GitHub
ce991cfa fabd694e

+248 -176
+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
+7 -2
Grain/Utilities/DateFormatting.swift
··· 2 2 3 3 enum DateFormatting { 4 4 /// Produce an ISO 8601 string with fractional seconds (matches JS `toISOString()`). 5 - static func nowISO() -> String { 5 + static func nowISO(date: Date = Date()) -> String { 6 6 let formatter = ISO8601DateFormatter() 7 7 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 8 - return formatter.string(from: Date()) 8 + return formatter.string(from: date) 9 9 } 10 10 11 11 /// Parse an ISO 8601 string with or without fractional seconds. ··· 18 18 /// Relative time string like "2h", "3d", "1w", or "Mar 5". 19 19 static func relativeTime(_ dateString: String) -> String { 20 20 guard let date = parse(dateString) else { return "" } 21 + return relativeTime(date) 22 + } 23 + 24 + /// Relative time from a `Date` value. 25 + static func relativeTime(_ date: Date) -> String { 21 26 let interval = Date().timeIntervalSince(date) 22 27 if interval < 60 { return "now" } 23 28 if interval < 3600 { return "\(Int(interval / 60))m" }
+147 -11
Grain/Utilities/PreviewData.swift
··· 51 51 avatar: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006") 52 52 ) 53 53 54 + static let profile6 = GrainProfile( 55 + cid: "cid6", did: "did:plc:prevuser6", 56 + handle: "rina.grain.social", displayName: "Rina Watanabe", 57 + avatar: nil 58 + ) 59 + 60 + static let profile7 = GrainProfile( 61 + cid: "cid7", did: "did:plc:prevuser7", 62 + handle: "omar.grain.social", displayName: "Omar Hassan", 63 + avatar: nil 64 + ) 65 + 66 + static let profile8 = GrainProfile( 67 + cid: "cid8", did: "did:plc:prevuser8", 68 + handle: "elena.grain.social", displayName: "Elena Voronova", 69 + avatar: bundleImageURL("Endeavour_after_STS-126_on_SCA_over_Mojave_from_above") 70 + ) 71 + 72 + // MARK: - Date helpers 73 + 74 + private static func ago(_ seconds: TimeInterval) -> String { 75 + DateFormatting.nowISO(date: Date().addingTimeInterval(-seconds)) 76 + } 77 + 78 + private static let minute: TimeInterval = 60 79 + private static let hour: TimeInterval = 3600 80 + private static let day: TimeInterval = 86400 81 + 54 82 // MARK: - Bundle image URL helper 55 83 56 84 static func bundleImageURL(_ name: String, ext: String = "jpg") -> String { ··· 367 395 // MARK: - Notifications 368 396 369 397 static let notifications: [GrainNotification] = [ 398 + // — Gallery favorite group: 4 users liked gallery1 within 48h → "Marcus and 3 others favorited your gallery" 370 399 GrainNotification( 371 400 uri: "at://did:plc:prevuser2/social.grain.notification/n1", 372 401 reason: "gallery-favorite", 373 - createdAt: "2025-01-10T19:30:00Z", 402 + createdAt: ago(2 * minute), 374 403 author: profile2, 375 404 galleryUri: gallery1.uri, 376 405 galleryTitle: gallery1.title, 377 - galleryThumb: "" 406 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 407 + ), 408 + GrainNotification( 409 + uri: "at://did:plc:prevuser6/social.grain.notification/n1b", 410 + reason: "gallery-favorite", 411 + createdAt: ago(15 * minute), 412 + author: profile6, 413 + galleryUri: gallery1.uri, 414 + galleryTitle: gallery1.title, 415 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 416 + ), 417 + GrainNotification( 418 + uri: "at://did:plc:prevuser7/social.grain.notification/n1c", 419 + reason: "gallery-favorite", 420 + createdAt: ago(1 * hour), 421 + author: profile7, 422 + galleryUri: gallery1.uri, 423 + galleryTitle: gallery1.title, 424 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 378 425 ), 426 + GrainNotification( 427 + uri: "at://did:plc:prevuser8/social.grain.notification/n1d", 428 + reason: "gallery-favorite", 429 + createdAt: ago(2 * hour), 430 + author: profile8, 431 + galleryUri: gallery1.uri, 432 + galleryTitle: gallery1.title, 433 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 434 + ), 435 + // — Single gallery comment 379 436 GrainNotification( 380 437 uri: "at://did:plc:prevuser3/social.grain.notification/n2", 381 438 reason: "gallery-comment", 382 - createdAt: "2025-01-10T19:00:00Z", 439 + createdAt: ago(3 * hour), 383 440 author: profile3, 384 441 galleryUri: gallery1.uri, 385 442 galleryTitle: gallery1.title, 386 - galleryThumb: "", 443 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb"), 387 444 commentText: "The light in the third frame is unreal. What film stock?" 388 445 ), 446 + // — Follow group: 3 users followed within 48h → "Kai and 2 others followed you" 389 447 GrainNotification( 390 448 uri: "at://did:plc:prevuser4/social.grain.notification/n3", 391 449 reason: "follow", 392 - createdAt: "2025-01-10T18:00:00Z", 393 - author: GrainProfile(cid: "c4", did: "did:plc:prevuser4", handle: "kai.grain.social", displayName: "Kai Müller") 450 + createdAt: ago(5 * hour), 451 + author: profile4 394 452 ), 395 453 GrainNotification( 454 + uri: "at://did:plc:prevuser6/social.grain.notification/n3b", 455 + reason: "follow", 456 + createdAt: ago(8 * hour), 457 + author: profile6 458 + ), 459 + GrainNotification( 460 + uri: "at://did:plc:prevuser7/social.grain.notification/n3c", 461 + reason: "follow", 462 + createdAt: ago(10 * hour), 463 + author: profile7 464 + ), 465 + // — Story favorite group: 2 users liked the same story → "Sofia and 1 other favorited your story" 466 + GrainNotification( 467 + uri: "at://did:plc:prevuser3/social.grain.notification/n6", 468 + reason: "story-favorite", 469 + createdAt: ago(1 * day), 470 + author: profile3, 471 + storyUri: stories[0].uri, 472 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb") 473 + ), 474 + GrainNotification( 475 + uri: "at://did:plc:prevuser5/social.grain.notification/n6b", 476 + reason: "story-favorite", 477 + createdAt: ago(1 * day + 2 * hour), 478 + author: profile5, 479 + storyUri: stories[0].uri, 480 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb") 481 + ), 482 + // — Single gallery favorite (different gallery, no group) 483 + GrainNotification( 396 484 uri: "at://did:plc:prevuser2/social.grain.notification/n4", 397 485 reason: "gallery-favorite", 398 - createdAt: "2025-01-09T12:00:00Z", 486 + createdAt: ago(2 * day), 399 487 author: profile2, 400 488 galleryUri: gallery2.uri, 401 489 galleryTitle: gallery2.title, 402 - galleryThumb: "" 490 + galleryThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 403 491 ), 492 + // — Single comment mention 404 493 GrainNotification( 405 494 uri: "at://did:plc:prevuser5/social.grain.notification/n5", 406 495 reason: "gallery-comment-mention", 407 - createdAt: "2025-01-09T10:00:00Z", 408 - author: GrainProfile(cid: "c5", did: "did:plc:prevuser5", handle: "leo.grain.social", displayName: "Leo Park"), 496 + createdAt: ago(3 * day), 497 + author: profile5, 409 498 galleryUri: gallery1.uri, 410 499 galleryTitle: gallery1.title, 411 - galleryThumb: "", 500 + galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 412 501 commentText: "Tagged you in a comment: @yuki.grain.social beautiful work!" 502 + ), 503 + // — Single gallery mention 504 + GrainNotification( 505 + uri: "at://did:plc:prevuser4/social.grain.notification/n7", 506 + reason: "gallery-mention", 507 + createdAt: ago(3 * day + 2 * hour), 508 + author: profile4, 509 + galleryUri: gallery3.uri, 510 + galleryTitle: gallery3.title, 511 + galleryThumb: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006_thumb") 512 + ), 513 + // — Single story comment 514 + GrainNotification( 515 + uri: "at://did:plc:prevuser6/social.grain.notification/n8", 516 + reason: "story-comment", 517 + createdAt: ago(3 * day + 5 * hour), 518 + author: profile6, 519 + storyUri: stories[0].uri, 520 + storyThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 521 + commentText: "Love the autumn colors here" 522 + ), 523 + // — Single reply 524 + GrainNotification( 525 + uri: "at://did:plc:prevuser7/social.grain.notification/n9", 526 + reason: "reply", 527 + createdAt: ago(4 * day), 528 + author: profile7, 529 + galleryUri: gallery1.uri, 530 + galleryTitle: gallery1.title, 531 + galleryThumb: bundleImageURL("Portland_Japanese_Garden_maple_thumb"), 532 + commentText: "Totally agree, Portra is unmatched for skin tones" 533 + ), 534 + // — Single story favorite (different story, won't group with the pair above) 535 + GrainNotification( 536 + uri: "at://did:plc:prevuser8/social.grain.notification/n10", 537 + reason: "story-favorite", 538 + createdAt: ago(4 * day + 3 * hour), 539 + author: profile8, 540 + storyUri: stories[1].uri, 541 + storyThumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon_thumb") 542 + ), 543 + // — Single follow (>48h from the group, won't merge) 544 + GrainNotification( 545 + uri: "at://did:plc:prevuser5/social.grain.notification/n11", 546 + reason: "follow", 547 + createdAt: ago(5 * day), 548 + author: profile5 413 549 ), 414 550 ] 415 551
+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 {
+2 -2
Grain/Views/Components/AvatarView.swift
··· 57 57 58 58 private var fallback: some View { 59 59 ZStack { 60 - Circle().fill(Color.gray.opacity(0.3)) 60 + Circle().fill(Color(.systemGray4)) 61 61 Image(systemName: "person.fill") 62 62 .font(.system(size: size * 0.45)) 63 - .foregroundStyle(Color.gray.opacity(0.6)) 63 + .foregroundStyle(Color(.systemGray2)) 64 64 } 65 65 } 66 66 }
+1 -1
Grain/Views/Components/GalleryCardView.swift
··· 474 474 (onCommentTap ?? onNavigate)() 475 475 } label: { 476 476 HStack(spacing: 5) { 477 - Image(systemName: "bubble.right") 477 + Image(systemName: "bubble") 478 478 .font(.system(size: 20)) 479 479 Text("\(gallery.commentCount ?? 0)") 480 480 }
+1 -16
Grain/Views/LoginView.swift
··· 1 - import NukeUI 2 1 import SwiftUI 3 2 4 3 struct LoginView: View { ··· 145 144 Task { await login() } 146 145 } label: { 147 146 HStack(spacing: 10) { 148 - if let avatar = actor.avatar, let url = URL(string: avatar) { 149 - LazyImage(url: url) { state in 150 - if let image = state.image { 151 - image.resizable().scaledToFill() 152 - } else { 153 - Circle().fill(.white.opacity(0.2)) 154 - } 155 - } 156 - .frame(width: 32, height: 32) 157 - .clipShape(Circle()) 158 - } else { 159 - Circle() 160 - .fill(.white.opacity(0.2)) 161 - .frame(width: 32, height: 32) 162 - } 147 + AvatarView(url: actor.avatar, size: 32) 163 148 164 149 VStack(alignment: .leading, spacing: 1) { 165 150 if let displayName = actor.displayName, !displayName.isEmpty {
+55 -126
Grain/Views/Notifications/NotificationsView.swift
··· 198 198 switch reason { 199 199 case .galleryFavorite, .storyFavorite: "heart.fill" 200 200 case .follow: "person.fill.badge.plus" 201 - case .galleryComment, .storyComment: "bubble.left.fill" 202 - case .reply: "arrowshape.turn.up.left.fill" 201 + case .galleryComment, .storyComment: "text.bubble.fill" 202 + case .reply: "arrowshape.turn.up.backward.fill" 203 203 case .galleryCommentMention, .galleryMention: "at" 204 204 case .unknown: "bell.fill" 205 205 } ··· 208 208 var body: some View { 209 209 Image(systemName: iconName) 210 210 .foregroundStyle(Color("AccentColor")) 211 - .font(.system(size: 14)) 211 + .font(.system(size: 18)) 212 212 .frame(width: 20) 213 213 } 214 214 } ··· 225 225 group.notification.galleryThumb ?? group.notification.storyThumb 226 226 } 227 227 228 + private var isStoryThumb: Bool { 229 + group.notification.galleryThumb == nil && group.notification.storyThumb != nil 230 + } 231 + 228 232 var body: some View { 229 233 HStack(alignment: .top, spacing: 10) { 230 234 ReasonIcon(reason: group.notification.reasonType) 231 - .padding(.top, 4) 235 + .frame(height: 38) 232 236 233 237 VStack(alignment: .leading, spacing: 6) { 234 238 HStack(spacing: 0) { ··· 262 266 onSubjectTap?() 263 267 } label: { 264 268 VStack(alignment: .leading, spacing: 4) { 265 - Text("\(Text(name).bold()) and \(others) \(reasonText) \(Text(DateFormatting.relativeTime(group.notification.createdAt)).foregroundStyle(.tertiary))") 269 + Text("\(Text(name).bold()) and \(others) \(reasonText) \(Text(DateFormatting.relativeTime(group.notification.createdAt)).foregroundStyle(.secondary))") 266 270 .font(.subheadline) 267 271 .foregroundStyle(.primary) 268 272 if group.notification.reasonType == .galleryFavorite, ··· 284 288 .overlay(alignment: .trailing) { 285 289 if let thumb { 286 290 Button { onSubjectTap?() } label: { 287 - CachedThumbnailView(url: thumb, height: 44) 291 + CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb) 288 292 } 289 293 .buttonStyle(.plain) 290 294 } ··· 312 316 notification.galleryThumb ?? notification.storyThumb 313 317 } 314 318 319 + private var isStoryThumb: Bool { 320 + notification.galleryThumb == nil && notification.storyThumb != nil 321 + } 322 + 315 323 var body: some View { 316 324 HStack(alignment: .top, spacing: 10) { 317 325 ReasonIcon(reason: notification.reasonType) 318 - .padding(.top, 4) 326 + .frame(height: 34) 319 327 320 328 VStack(alignment: .leading, spacing: 6) { 321 329 AvatarView(url: notification.author.avatar, size: 34, animated: false) ··· 324 332 } 325 333 326 334 VStack(alignment: .leading, spacing: 2) { 327 - Text("\(Text(notification.author.displayName ?? notification.author.handle).bold()) \(reasonText) \(Text(DateFormatting.relativeTime(notification.createdAt)).foregroundStyle(.tertiary))") 335 + Text("\(Text(notification.author.displayName ?? notification.author.handle).bold()) \(reasonText) \(Text(DateFormatting.relativeTime(notification.createdAt)).foregroundStyle(.secondary))") 328 336 .font(.subheadline) 329 337 .foregroundStyle(.primary) 330 338 if notification.reasonType == .galleryFavorite, ··· 349 357 .overlay(alignment: .trailing) { 350 358 if let thumb { 351 359 Button { onSubjectTap?() } label: { 352 - CachedThumbnailView(url: thumb, height: 44) 360 + CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb) 353 361 } 354 362 .buttonStyle(.plain) 355 363 } ··· 371 379 } 372 380 } 373 381 374 - // MARK: - Overlapping Avatars (UIKit-backed, zero SwiftUI layout participation) 382 + // MARK: - Overlapping Avatars 375 383 376 - private struct OverlappingAvatarsView: UIViewRepresentable { 384 + private struct OverlappingAvatarsView: View { 377 385 let authors: [GrainProfile] 378 386 let size: CGFloat 379 387 let overlap: CGFloat 380 388 var onProfileTap: ((String) -> Void)? 381 389 382 - private var totalWidth: CGFloat { 383 - guard !authors.isEmpty else { return 0 } 384 - return size + CGFloat(authors.count - 1) * (size - overlap) 390 + private var step: CGFloat { 391 + size - overlap 385 392 } 386 393 387 - func makeUIView(context _: Context) -> OverlappingAvatarsUIView { 388 - let view = OverlappingAvatarsUIView() 389 - view.onProfileTap = onProfileTap 390 - view.configure(authors: authors, size: size, overlap: overlap) 391 - return view 392 - } 393 - 394 - func updateUIView(_ uiView: OverlappingAvatarsUIView, context _: Context) { 395 - uiView.onProfileTap = onProfileTap 396 - uiView.configure(authors: authors, size: size, overlap: overlap) 397 - } 398 - 399 - func sizeThatFits(_: ProposedViewSize, uiView _: OverlappingAvatarsUIView, context _: Context) -> CGSize? { 400 - CGSize(width: totalWidth, height: size) 401 - } 402 - } 403 - 404 - final class OverlappingAvatarsUIView: UIView { 405 - var onProfileTap: ((String) -> Void)? 406 - private var avatarViews: [UIImageView] = [] 407 - private var authorDids: [String] = [] 408 - private var currentKey = "" 409 - 410 - override init(frame: CGRect) { 411 - super.init(frame: frame) 412 - isUserInteractionEnabled = true 413 - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: OverlappingAvatarsUIView, _) in 414 - for iv in view.avatarViews { 415 - iv.layer.borderColor = UIColor.systemBackground.cgColor 416 - } 417 - } 418 - } 419 - 420 - @available(*, unavailable) 421 - required init?(coder _: NSCoder) { 422 - fatalError() 423 - } 424 - 425 - func configure(authors: [GrainProfile], size: CGFloat, overlap: CGFloat) { 426 - let key = authors.map(\.did).joined(separator: ",") 427 - guard key != currentKey else { return } 428 - currentKey = key 429 - authorDids = authors.map(\.did) 430 - 431 - // Remove old views 432 - avatarViews.forEach { $0.removeFromSuperview() } 433 - avatarViews.removeAll() 434 - 435 - let step = size - overlap 436 - 437 - for (i, author) in authors.enumerated() { 438 - let iv = UIImageView() 439 - iv.contentMode = .scaleAspectFill 440 - iv.clipsToBounds = true 441 - iv.layer.cornerRadius = size / 2 442 - iv.layer.borderWidth = 2 443 - iv.layer.borderColor = UIColor.systemBackground.cgColor 444 - iv.backgroundColor = UIColor.gray.withAlphaComponent(0.3) 445 - iv.frame = CGRect(x: CGFloat(i) * step, y: 0, width: size, height: size) 446 - 447 - // Load from Nuke memory cache synchronously, or fetch async 448 - if let url = author.avatar, let imageURL = URL(string: url) { 449 - let request = ImageRequest(url: imageURL) 450 - if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image { 451 - iv.image = cached 452 - } else { 453 - Task { @MainActor in 454 - if let image = try? await ImagePipeline.shared.image(for: request) { 455 - iv.image = image 456 - } 457 - } 394 + var body: some View { 395 + HStack(spacing: -overlap) { 396 + ForEach(Array(authors.enumerated()), id: \.element.did) { i, author in 397 + Button { 398 + onProfileTap?(author.did) 399 + } label: { 400 + AvatarView(url: author.avatar, size: size, animated: false) 401 + .overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 2)) 458 402 } 459 - } 460 - 461 - addSubview(iv) 462 - avatarViews.append(iv) 463 - } 464 - 465 - let totalWidth = size + CGFloat(authors.count - 1) * step 466 - frame.size = CGSize(width: totalWidth, height: size) 467 - invalidateIntrinsicContentSize() 468 - } 469 - 470 - override var intrinsicContentSize: CGSize { 471 - frame.size 472 - } 473 - 474 - override func hitTest(_ point: CGPoint, with _: UIEvent?) -> UIView? { 475 - // Claim hit for any touch inside an avatar circle 476 - for iv in avatarViews.reversed() { 477 - if iv.frame.contains(point) { return self } 478 - } 479 - return nil 480 - } 481 - 482 - override func touchesEnded(_ touches: Set<UITouch>, with _: UIEvent?) { 483 - guard let touch = touches.first else { return } 484 - let location = touch.location(in: self) 485 - // Check avatars in reverse order (topmost first) 486 - for (i, iv) in avatarViews.enumerated().reversed() { 487 - if iv.frame.contains(location) { 488 - if i < authorDids.count { 489 - onProfileTap?(authorDids[i]) 490 - } 491 - return 403 + .buttonStyle(.plain) 404 + .zIndex(Double(authors.count - i)) 492 405 } 493 406 } 407 + .fixedSize() 494 408 } 495 409 } 496 410 ··· 498 412 499 413 private struct CachedThumbnailView: View { 500 414 let url: String 501 - let height: CGFloat 415 + let size: CGFloat 416 + var portrait: Bool = false 417 + 418 + private var width: CGFloat { 419 + size 420 + } 421 + 422 + private var height: CGFloat { 423 + portrait ? size * 4 / 3 : size 424 + } 502 425 503 426 @State private var asyncImage: UIImage? 504 427 ··· 506 429 URL(string: url) 507 430 } 508 431 432 + private var thumbRequest: ImageRequest? { 433 + guard let imageURL else { return nil } 434 + let scale = UIScreen.main.scale 435 + let targetSize = CGSize(width: width * scale, height: height * scale) 436 + return ImageRequest(url: imageURL, processors: [.resize(size: targetSize, contentMode: .aspectFill)]) 437 + } 438 + 509 439 private var resolvedImage: UIImage? { 510 - if let imageURL, 511 - let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image 440 + if let request = thumbRequest, 441 + let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image 512 442 { 513 443 return cached 514 444 } ··· 520 450 if let image = resolvedImage { 521 451 Image(uiImage: image) 522 452 .resizable() 523 - .aspectRatio(contentMode: .fit) 453 + .aspectRatio(contentMode: .fill) 524 454 } else { 525 455 Rectangle().fill(.quaternary) 526 - .aspectRatio(1, contentMode: .fit) 527 456 } 528 457 } 529 - .frame(height: height) 458 + .frame(width: width, height: height) 530 459 .clipShape(.rect(cornerRadius: 6)) 531 460 .onAppear { loadIfNeeded() } 532 461 } 533 462 534 463 private func loadIfNeeded() { 535 - guard let imageURL else { return } 536 - let request = ImageRequest(url: imageURL) 464 + guard let request = thumbRequest else { return } 537 465 if ImagePipeline.shared.cache.cachedImage(for: request) != nil { return } 538 466 guard asyncImage == nil else { return } 539 467 Task { ··· 610 538 vm.unseenCount = 3 611 539 return NotificationsView(client: client, viewModel: vm) 612 540 .previewEnvironments() 541 + .grainPreview() 613 542 .frame(maxHeight: .infinity, alignment: .top) 614 543 }
+2 -13
Grain/Views/Stories/StoryViewer.swift
··· 323 323 Text(story?.creator.displayName ?? story?.creator.handle ?? authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 324 324 .font(.subheadline.bold()) 325 325 .foregroundStyle(.white) 326 - Text(story.map { relativeTime($0.createdAt) } ?? " ") 326 + Text(story.map { DateFormatting.relativeTime($0.createdAt) } ?? " ") 327 327 .font(.caption2) 328 328 .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 329 329 .animation(.easeIn(duration: 0.12), value: story != nil) ··· 536 536 Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 537 537 .font(.subheadline.bold()) 538 538 .foregroundStyle(.white) 539 - Text(story.map { relativeTime($0.createdAt) } ?? " ") 539 + Text(story.map { DateFormatting.relativeTime($0.createdAt) } ?? " ") 540 540 .font(.caption2) 541 541 .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 542 542 .animation(.easeIn(duration: 0.12), value: story != nil) ··· 1028 1028 return parts.joined(separator: ", ") 1029 1029 } 1030 1030 return nil 1031 - } 1032 - 1033 - private func relativeTime(_ dateString: String) -> String { 1034 - let formatter = ISO8601DateFormatter() 1035 - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 1036 - guard let date = formatter.date(from: dateString) else { return "" } 1037 - let interval = Date().timeIntervalSince(date) 1038 - if interval < 60 { return "now" } 1039 - if interval < 3600 { return "\(Int(interval / 60))m" } 1040 - if interval < 86400 { return "\(Int(interval / 3600))h" } 1041 - return "\(Int(interval / 86400))d" 1042 1031 } 1043 1032 1044 1033 // MARK: - Comments & Likes