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: DRY relative date formatting, use dynamic preview dates

Extract Date overload for relativeTime, remove duplicate in
StoryViewer, and replace hardcoded preview notification dates
with dynamic offsets so previews always show relative timestamps.

+31 -27
+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" }
+22 -12
Grain/Utilities/PreviewData.swift
··· 69 69 avatar: bundleImageURL("Endeavour_after_STS-126_on_SCA_over_Mojave_from_above") 70 70 ) 71 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 + 72 82 // MARK: - Bundle image URL helper 73 83 74 84 static func bundleImageURL(_ name: String, ext: String = "jpg") -> String { ··· 389 399 GrainNotification( 390 400 uri: "at://did:plc:prevuser2/social.grain.notification/n1", 391 401 reason: "gallery-favorite", 392 - createdAt: "2025-01-10T19:30:00Z", 402 + createdAt: ago(2 * minute), 393 403 author: profile2, 394 404 galleryUri: gallery1.uri, 395 405 galleryTitle: gallery1.title, ··· 398 408 GrainNotification( 399 409 uri: "at://did:plc:prevuser6/social.grain.notification/n1b", 400 410 reason: "gallery-favorite", 401 - createdAt: "2025-01-10T18:45:00Z", 411 + createdAt: ago(15 * minute), 402 412 author: profile6, 403 413 galleryUri: gallery1.uri, 404 414 galleryTitle: gallery1.title, ··· 407 417 GrainNotification( 408 418 uri: "at://did:plc:prevuser7/social.grain.notification/n1c", 409 419 reason: "gallery-favorite", 410 - createdAt: "2025-01-10T17:20:00Z", 420 + createdAt: ago(1 * hour), 411 421 author: profile7, 412 422 galleryUri: gallery1.uri, 413 423 galleryTitle: gallery1.title, ··· 416 426 GrainNotification( 417 427 uri: "at://did:plc:prevuser8/social.grain.notification/n1d", 418 428 reason: "gallery-favorite", 419 - createdAt: "2025-01-10T16:00:00Z", 429 + createdAt: ago(2 * hour), 420 430 author: profile8, 421 431 galleryUri: gallery1.uri, 422 432 galleryTitle: gallery1.title, ··· 426 436 GrainNotification( 427 437 uri: "at://did:plc:prevuser3/social.grain.notification/n2", 428 438 reason: "gallery-comment", 429 - createdAt: "2025-01-10T19:00:00Z", 439 + createdAt: ago(3 * hour), 430 440 author: profile3, 431 441 galleryUri: gallery1.uri, 432 442 galleryTitle: gallery1.title, ··· 437 447 GrainNotification( 438 448 uri: "at://did:plc:prevuser4/social.grain.notification/n3", 439 449 reason: "follow", 440 - createdAt: "2025-01-10T18:00:00Z", 450 + createdAt: ago(5 * hour), 441 451 author: profile4 442 452 ), 443 453 GrainNotification( 444 454 uri: "at://did:plc:prevuser6/social.grain.notification/n3b", 445 455 reason: "follow", 446 - createdAt: "2025-01-10T15:00:00Z", 456 + createdAt: ago(8 * hour), 447 457 author: profile6 448 458 ), 449 459 GrainNotification( 450 460 uri: "at://did:plc:prevuser7/social.grain.notification/n3c", 451 461 reason: "follow", 452 - createdAt: "2025-01-10T14:30:00Z", 462 + createdAt: ago(10 * hour), 453 463 author: profile7 454 464 ), 455 465 // — Story favorite group: 2 users liked the same story → "Sofia and 1 other favorited your story" 456 466 GrainNotification( 457 467 uri: "at://did:plc:prevuser3/social.grain.notification/n6", 458 468 reason: "story-favorite", 459 - createdAt: "2025-01-10T13:00:00Z", 469 + createdAt: ago(1 * day), 460 470 author: profile3, 461 471 storyUri: stories[0].uri, 462 472 storyThumb: bundleImageURL("Portland_Japanese_Garden_maple") ··· 464 474 GrainNotification( 465 475 uri: "at://did:plc:prevuser5/social.grain.notification/n6b", 466 476 reason: "story-favorite", 467 - createdAt: "2025-01-10T12:30:00Z", 477 + createdAt: ago(1 * day + 2 * hour), 468 478 author: profile5, 469 479 storyUri: stories[0].uri, 470 480 storyThumb: bundleImageURL("Portland_Japanese_Garden_maple") ··· 473 483 GrainNotification( 474 484 uri: "at://did:plc:prevuser2/social.grain.notification/n4", 475 485 reason: "gallery-favorite", 476 - createdAt: "2025-01-09T12:00:00Z", 486 + createdAt: ago(2 * day), 477 487 author: profile2, 478 488 galleryUri: gallery2.uri, 479 489 galleryTitle: gallery2.title, ··· 483 493 GrainNotification( 484 494 uri: "at://did:plc:prevuser5/social.grain.notification/n5", 485 495 reason: "gallery-comment-mention", 486 - createdAt: "2025-01-09T10:00:00Z", 496 + createdAt: ago(3 * day), 487 497 author: profile5, 488 498 galleryUri: gallery1.uri, 489 499 galleryTitle: gallery1.title,
+2 -13
Grain/Views/Stories/StoryViewer.swift
··· 307 307 Text(story?.creator.displayName ?? story?.creator.handle ?? authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 308 308 .font(.subheadline.bold()) 309 309 .foregroundStyle(.white) 310 - Text(story.map { relativeTime($0.createdAt) } ?? " ") 310 + Text(story.map { DateFormatting.relativeTime($0.createdAt) } ?? " ") 311 311 .font(.caption2) 312 312 .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 313 313 .animation(.easeIn(duration: 0.12), value: story != nil) ··· 531 531 Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 532 532 .font(.subheadline.bold()) 533 533 .foregroundStyle(.white) 534 - Text(story.map { relativeTime($0.createdAt) } ?? " ") 534 + Text(story.map { DateFormatting.relativeTime($0.createdAt) } ?? " ") 535 535 .font(.caption2) 536 536 .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 537 537 .animation(.easeIn(duration: 0.12), value: story != nil) ··· 1004 1004 return parts.joined(separator: ", ") 1005 1005 } 1006 1006 return nil 1007 - } 1008 - 1009 - private func relativeTime(_ dateString: String) -> String { 1010 - let formatter = ISO8601DateFormatter() 1011 - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 1012 - guard let date = formatter.date(from: dateString) else { return "" } 1013 - let interval = Date().timeIntervalSince(date) 1014 - if interval < 60 { return "now" } 1015 - if interval < 3600 { return "\(Int(interval / 60))m" } 1016 - if interval < 86400 { return "\(Int(interval / 3600))h" } 1017 - return "\(Int(interval / 86400))d" 1018 1007 } 1019 1008 1020 1009 // MARK: - Comments & Likes