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.

feat: inline story time with username, location on row two

Time now appears inline with the username on row 1 (caption2, dimmed);
location sits alone on row 2. Placeholder Text reserves two-line height
so the username never shifts when time/location fade in (0.12s easeIn).
Also removes GrainProfile.viewer field (fully unused after last refactor).

+104 -84
-1
Grain/Models/Views/ProfileModels.swift
··· 10 10 var labels: [ATLabel]? 11 11 var avatar: String? 12 12 var createdAt: String? 13 - var viewer: ActorViewerState? 14 13 15 14 var id: String { 16 15 did
+5
Grain/ViewModels/StoryCommentsViewModel.swift
··· 7 7 @MainActor 8 8 final class StoryCommentsViewModel { 9 9 var comments: [GrainComment] = [] 10 + // latestComment is set to response.comments.first (oldest/chronological). 11 + // Ideally this would prefer comments from followed users, then fall back to most-liked, 12 + // but the getGalleryThread endpoint doesn't return viewer state on authors or likeCount 13 + // on comments. Bumping the preview fetch from limit=1 to support client-side selection 14 + // also adds ~100ms per request. Revisit when the backend hydrates those fields. 10 15 var latestComment: GrainComment? 11 16 var totalCount: Int = 0 12 17 var isLoading = false
+99 -83
Grain/Views/Stories/StoryViewer.swift
··· 106 106 @State private var timer = StoryTimer() 107 107 @State private var showDeleteConfirm = false 108 108 @State private var reportTarget: GrainStory? 109 - @State private var showLocationCopied = false 110 109 @State private var lastNavTime: Date = .distantPast 111 110 @State private var labelRevealed = false 112 111 @State private var imageLoaded = false ··· 321 320 HStack(alignment: .center, spacing: 8) { 322 321 AvatarView(url: authors[authorIdx].profile.avatar, size: 32, animated: false) 323 322 VStack(alignment: .leading, spacing: 0) { 324 - Text(story?.creator.displayName ?? story?.creator.handle ?? authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 325 - .font(.subheadline.bold()) 326 - .foregroundStyle(.white) 327 - if let story { 328 - Text(relativeTime(story.createdAt)) 323 + HStack(alignment: .firstTextBaseline, spacing: 5) { 324 + Text(story?.creator.displayName ?? story?.creator.handle ?? authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 325 + .font(.subheadline.bold()) 326 + .foregroundStyle(.white) 327 + Text(story.map { relativeTime($0.createdAt) } ?? " ") 329 328 .font(.caption2) 330 - .foregroundStyle(.white.opacity(0.7)) 329 + .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 330 + .animation(.easeIn(duration: 0.12), value: story != nil) 331 331 } 332 + Text(story.flatMap { storyLocationText($0) } ?? " ") 333 + .font(.caption2) 334 + .foregroundStyle(.white.opacity(story.flatMap { storyLocationText($0) } != nil ? 0.7 : 0)) 335 + .lineLimit(1) 336 + .animation(.easeIn(duration: 0.12), value: story.flatMap { storyLocationText($0) } != nil) 332 337 } 333 338 Spacer() 334 339 if authors[authorIdx].profile.did == auth.userDID { ··· 349 354 .padding(.vertical, 8) 350 355 351 356 Spacer().allowsHitTesting(false) 357 + 358 + bottomInputBar(interactive: false) 352 359 } 353 360 } 354 361 } ··· 533 540 HStack(alignment: .center, spacing: 8) { 534 541 storyAvatarView(url: story?.creator.avatar ?? author.avatar) 535 542 VStack(alignment: .leading, spacing: 0) { 536 - Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 537 - .font(.subheadline.bold()) 538 - .foregroundStyle(.white) 539 - if let story { 540 - Text(relativeTime(story.createdAt)) 543 + HStack(alignment: .firstTextBaseline, spacing: 5) { 544 + Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 545 + .font(.subheadline.bold()) 546 + .foregroundStyle(.white) 547 + Text(story.map { relativeTime($0.createdAt) } ?? " ") 541 548 .font(.caption2) 542 - .foregroundStyle(.white.opacity(0.7)) 549 + .foregroundStyle(.white.opacity(story != nil ? 0.7 : 0)) 550 + .animation(.easeIn(duration: 0.12), value: story != nil) 543 551 } 552 + Text(story.flatMap { storyLocationText($0) } ?? " ") 553 + .font(.caption2) 554 + .foregroundStyle(.white.opacity(story.flatMap { storyLocationText($0) } != nil ? 0.7 : 0)) 555 + .lineLimit(1) 556 + .animation(.easeIn(duration: 0.12), value: story.flatMap { storyLocationText($0) } != nil) 544 557 } 545 558 } 546 559 } ··· 584 597 // MARK: Comment preview + input bar 585 598 586 599 if let currentStory { 587 - let locationText = storyLocationText(currentStory) 588 - 589 - if commentsViewModel.latestComment != nil || locationText != nil { 590 - HStack(spacing: 6) { 591 - if let latest = commentsViewModel.latestComment { 592 - Button { 593 - openCommentSheet(focusInput: false) 594 - } label: { 595 - HStack(spacing: 6) { 596 - AvatarView(url: latest.author.avatar, size: 20, animated: false) 597 - .padding(.leading, 8) 598 - Text("**\(latest.author.displayName ?? latest.author.handle)** \(latest.text)") 599 - .font(.caption) 600 - .foregroundStyle(.white) 601 - .lineLimit(1) 602 - } 603 - } 604 - .buttonStyle(.plain) 605 - } 606 - Spacer(minLength: 8) 607 - if let locationText { 608 - HStack(spacing: 4) { 609 - Image(systemName: showLocationCopied ? "checkmark" : "location.fill") 610 - Text(showLocationCopied ? "Copied" : locationText) 611 - } 612 - .font(.caption) 613 - .foregroundStyle(.white) 614 - .padding(.horizontal, 12) 615 - .padding(.vertical, 6) 616 - .background(.ultraThinMaterial, in: Capsule()) 617 - .contentTransition(.symbolEffect(.replace)) 618 - .id(currentStory.uri) 619 - .onTapGesture { 620 - UIPasteboard.general.string = locationText 621 - withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = true } 622 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 623 - withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = false } 624 - } 600 + Group { 601 + if let latest = commentsViewModel.latestComment, 602 + commentsViewModel.activeStoryUri == currentStory.uri 603 + { 604 + Button { 605 + openCommentSheet(focusInput: false) 606 + } label: { 607 + HStack(spacing: 6) { 608 + AvatarView(url: latest.author.avatar, size: 20, animated: false) 609 + .padding(.leading, 8) 610 + Text("**\(latest.author.displayName ?? latest.author.handle)** \(latest.text)") 611 + .font(.caption) 612 + .foregroundStyle(.white) 613 + .lineLimit(1) 614 + Spacer(minLength: 0) 625 615 } 626 616 } 617 + .buttonStyle(.plain) 618 + .padding(.leading, 16) 619 + .padding(.trailing, 64) 620 + .padding(.bottom, 4) 621 + .transition(.opacity) 627 622 } 628 - .padding(.horizontal, 16) 629 - .padding(.bottom, 4) 630 623 } 624 + .animation(.easeInOut(duration: 0.25), value: commentsViewModel.latestComment?.uri) 631 625 632 - storyInputBar 626 + bottomInputBar(interactive: true) 633 627 } 634 628 } 635 629 } ··· 718 712 imageLoaded = false 719 713 } 720 714 labelRevealed = false 721 - showLocationCopied = false 722 715 prefetchStoryImages() 723 716 if let uri = nextStory?.uri { 724 717 Task { await commentsViewModel.switchToStory(uri: uri, auth: auth.authContext()) } ··· 938 931 } 939 932 let targetStory = fetched.indices.contains(targetIndex) ? fetched[targetIndex] : nil 940 933 if !isFullsizeCached(targetStory) { imageLoaded = false } 941 - showLocationCopied = false 942 934 stories = fetched 943 935 currentStoryIndex = targetIndex 944 936 labelRevealed = false ··· 1086 1078 return story.viewer?.fav != nil || storyFavoriteCache.isLiked(story.uri) 1087 1079 } 1088 1080 1089 - private var storyInputBar: some View { 1081 + private func bottomInputBar(interactive: Bool) -> some View { 1090 1082 HStack(spacing: 12) { 1091 1083 // Comment bubble — opens comment list 1092 - Button { 1093 - openCommentSheet(focusInput: false) 1094 - } label: { 1084 + if interactive { 1085 + Button { 1086 + openCommentSheet(focusInput: false) 1087 + } label: { 1088 + Image(systemName: "bubble") 1089 + .font(.body) 1090 + .foregroundStyle(.white) 1091 + .frame(width: 36, height: 36) 1092 + .contentShape(Rectangle()) 1093 + } 1094 + .buttonStyle(.plain) 1095 + } else { 1095 1096 Image(systemName: "bubble") 1096 1097 .font(.body) 1097 1098 .foregroundStyle(.white) 1098 1099 .frame(width: 36, height: 36) 1099 - .contentShape(Rectangle()) 1100 1100 } 1101 - .buttonStyle(.plain) 1102 1101 1103 1102 // "Add a comment..." — opens sheet with keyboard 1104 - Button { 1105 - openCommentSheet(focusInput: true) 1106 - } label: { 1103 + if interactive { 1104 + Button { 1105 + openCommentSheet(focusInput: true) 1106 + } label: { 1107 + Text("Add a comment...") 1108 + .font(.subheadline) 1109 + .foregroundStyle(.white.opacity(0.6)) 1110 + .frame(maxWidth: .infinity, alignment: .leading) 1111 + .padding(.horizontal, 18) 1112 + .padding(.vertical, 12) 1113 + } 1114 + .buttonStyle(.plain) 1115 + .glassEffect(.regular, in: .capsule) 1116 + } else { 1107 1117 Text("Add a comment...") 1108 1118 .font(.subheadline) 1109 1119 .foregroundStyle(.white.opacity(0.6)) 1110 1120 .frame(maxWidth: .infinity, alignment: .leading) 1111 1121 .padding(.horizontal, 18) 1112 1122 .padding(.vertical, 12) 1123 + .glassEffect(.regular, in: .capsule) 1113 1124 } 1114 - .buttonStyle(.plain) 1115 - .glassEffect(.regular, in: .capsule) 1116 1125 1117 1126 // Heart — like/unlike 1118 - Button { 1119 - if !isFavorited { addLikeParticleBurst() } 1120 - triggerFavoriteToggle() 1121 - } label: { 1122 - Image(systemName: isFavorited ? "heart.fill" : "heart") 1123 - .font(.title3) 1124 - .foregroundStyle(isFavorited ? Color("AccentColor") : .white) 1125 - .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 1126 - .frame(width: 36, height: 36) 1127 - .contentShape(Rectangle()) 1128 - } 1129 - .buttonStyle(.plain) 1130 - .overlay { 1131 - ForEach(likeParticleBursts, id: \.self) { _ in 1132 - ForEach(0 ..< 5) { i in 1133 - LikeParticleView(index: i) 1127 + if interactive { 1128 + Button { 1129 + if !isFavorited { addLikeParticleBurst() } 1130 + triggerFavoriteToggle() 1131 + } label: { 1132 + Image(systemName: isFavorited ? "heart.fill" : "heart") 1133 + .font(.title3) 1134 + .foregroundStyle(isFavorited ? Color("AccentColor") : .white) 1135 + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 1136 + .frame(width: 36, height: 36) 1137 + .contentShape(Rectangle()) 1138 + } 1139 + .buttonStyle(.plain) 1140 + .overlay { 1141 + ForEach(likeParticleBursts, id: \.self) { _ in 1142 + ForEach(0 ..< 5) { i in 1143 + LikeParticleView(index: i) 1144 + } 1134 1145 } 1135 1146 } 1147 + } else { 1148 + Image(systemName: "heart") 1149 + .font(.title3) 1150 + .foregroundStyle(.white) 1151 + .frame(width: 36, height: 36) 1136 1152 } 1137 1153 } 1138 1154 .padding(.horizontal, 16)