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: integrate comments and likes into StoryViewer

- Instagram-style floating comment bar at bottom (bubble icon, "Add a
comment..." placeholder, heart button)
- Latest comment preview above the bar, tappable to open comment list
- Comment sheet opens at medium detent, expandable to large
- Two sheet modes: input-focused (keyboard) vs list-browsing
- Double-tap anywhere to like with heart ripple animation
- Heart button with particle burst and optimistic favorite toggle
- Timer pauses when sheet opens, resumes on dismiss
- Story navigation (tap/swipe) blocked while sheet is open
- Comment preview loaded per-story via cache-first switchToStory

+191 -2
+191 -2
Grain/Views/Stories/StoryViewer.swift
··· 61 61 @Environment(AuthManager.self) private var auth 62 62 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 63 63 @Environment(ViewedStoryStorage.self) private var viewedStories 64 + @Environment(StoryStatusCache.self) private var storyStatusCache 64 65 let authors: [GrainStoryAuthor] 65 66 let client: XRPCClient 66 67 var onProfileTap: ((String) -> Void)? ··· 87 88 @State private var imagePrefetcher = ImagePrefetcher() 88 89 @State private var isDragging = false 89 90 91 + // MARK: - Comments & Likes 92 + 93 + @State private var commentsViewModel: StoryCommentsViewModel 94 + @State private var showCommentSheet = false 95 + @State private var commentSheetFocusInput = false 96 + @State private var hearts: [HeartAnimationState] = [] 97 + @State private var isFavoriting = false 98 + @State private var likeParticleBursts: [UUID] = [] 99 + 90 100 init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, initialStories: [GrainStory]? = nil, startStoryIndex: Int? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 91 101 self.authors = authors 92 102 self.client = client 93 103 self.onProfileTap = onProfileTap 94 104 self.onDismiss = onDismiss 105 + _commentsViewModel = State(initialValue: StoryCommentsViewModel(client: client)) 95 106 let resolvedIndex = startAuthorDid.flatMap { did in authors.firstIndex { $0.profile.did == did } } ?? 0 96 107 _currentAuthorIndex = State(initialValue: resolvedIndex) 97 108 if let initialStories { ··· 172 183 .onChange(of: reportTarget?.uri) { 173 184 if reportTarget == nil { timer.start() } 174 185 } 186 + .sheet(isPresented: $showCommentSheet) { 187 + if let story = currentStory { 188 + StoryCommentSheet( 189 + viewModel: commentsViewModel, 190 + storyUri: story.uri, 191 + client: client, 192 + focusInput: commentSheetFocusInput, 193 + onProfileTap: { did in 194 + showCommentSheet = false 195 + close() 196 + onProfileTap?(did) 197 + }, 198 + onDismiss: { showCommentSheet = false } 199 + ) 200 + .environment(auth) 201 + .environment(storyStatusCache) 202 + .environment(viewedStories) 203 + } 204 + } 205 + .onChange(of: showCommentSheet) { _, isShowing in 206 + if !isShowing { startTimerIfSafe() } 207 + } 175 208 .task { 176 209 guard !isPreview else { return } 177 210 let startAuthor = authors[currentAuthorIndex] ··· 423 456 HStack(spacing: 0) { 424 457 Color.clear 425 458 .contentShape(Rectangle()) 459 + .onTapGesture(count: 2) { doubleTapLike(at: CGPoint(x: geo.size.width / 6, y: geo.size.height / 2)) } 426 460 .onTapGesture { goToPrevious() } 427 461 .frame(width: geo.size.width / 3) 428 462 Color.clear 429 463 .contentShape(Rectangle()) 464 + .onTapGesture(count: 2) { doubleTapLike(at: CGPoint(x: geo.size.width * 2 / 3, y: geo.size.height / 2)) } 430 465 .onTapGesture { goToNext() } 431 466 .frame(maxWidth: .infinity) 432 467 } 433 468 } 434 469 } 435 - .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 470 + 471 + // Double-tap heart animations 472 + ForEach(hearts) { heart in 473 + DoubleTapHeartView(state: heart) 474 + .onChange(of: heart.isComplete) { 475 + hearts.removeAll { $0.isComplete } 476 + } 477 + } 478 + .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && !showCommentSheet && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 436 479 } else { 437 480 ProgressView() 438 481 .tint(.white) ··· 527 570 Spacer() 528 571 } 529 572 .padding(.horizontal) 530 - .padding(.bottom, 32) 573 + .padding(.bottom, 8) 574 + } 575 + 576 + // MARK: Comment preview + input bar 577 + 578 + if currentStory != nil { 579 + // Latest comment preview 580 + if let latest = commentsViewModel.latestComment { 581 + Button { 582 + timer.stop() 583 + commentSheetFocusInput = false 584 + showCommentSheet = true 585 + } label: { 586 + HStack(spacing: 6) { 587 + AvatarView(url: latest.author.avatar, size: 20, animated: false) 588 + Text("**\(latest.author.displayName ?? latest.author.handle)** \(latest.text)") 589 + .font(.caption) 590 + .foregroundStyle(.white) 591 + .lineLimit(1) 592 + } 593 + .padding(.horizontal) 594 + .padding(.bottom, 4) 595 + } 596 + .buttonStyle(.plain) 597 + } 598 + 599 + // TODO(human): Implement the story comment input bar 600 + storyInputBar 531 601 } 532 602 } 533 603 } ··· 543 613 private func canNavigate() -> Bool { 544 614 !isLoadingStories && !stories.isEmpty 545 615 && reportTarget == nil && !showDeleteConfirm 616 + && !showCommentSheet 546 617 && !isDragging 547 618 && Date().timeIntervalSince(lastNavTime) > 0.3 548 619 } ··· 593 664 labelRevealed = false 594 665 showLocationCopied = false 595 666 prefetchStoryImages() 667 + if let uri = nextStory?.uri { 668 + Task { await commentsViewModel.switchToStory(uri: uri, auth: auth.authContext()) } 669 + } 596 670 } 597 671 598 672 private func goToNextAuthor() { ··· 809 883 startTimerIfSafe() 810 884 prefetchAdjacentAuthors() 811 885 prefetchStoryImages() 886 + if let uri = targetStory?.uri { 887 + Task { await commentsViewModel.switchToStory(uri: uri, auth: auth.authContext()) } 888 + } 812 889 } 813 890 814 891 private func prefetchAdjacentAuthors() { ··· 904 981 if interval < 86400 { return "\(Int(interval / 3600))h" } 905 982 return "\(Int(interval / 86400))d" 906 983 } 984 + 985 + // MARK: - Comments & Likes 986 + 987 + private var isFavorited: Bool { 988 + currentStory?.viewer?.fav != nil 989 + } 990 + 991 + private var storyInputBar: some View { 992 + HStack(spacing: 12) { 993 + // Comment bubble — opens comment list 994 + Button { 995 + timer.stop() 996 + commentSheetFocusInput = false 997 + showCommentSheet = true 998 + } label: { 999 + Image(systemName: "bubble.left") 1000 + .font(.body) 1001 + .foregroundStyle(.white) 1002 + } 1003 + 1004 + // "Add a comment..." — opens sheet with keyboard 1005 + Button { 1006 + timer.stop() 1007 + commentSheetFocusInput = true 1008 + showCommentSheet = true 1009 + } label: { 1010 + Text("Add a comment...") 1011 + .font(.subheadline) 1012 + .foregroundStyle(.white.opacity(0.6)) 1013 + .frame(maxWidth: .infinity, alignment: .leading) 1014 + .padding(.horizontal, 14) 1015 + .padding(.vertical, 10) 1016 + .background(.white.opacity(0.15), in: Capsule()) 1017 + } 1018 + 1019 + // Heart — like/unlike 1020 + Button { 1021 + if !isFavorited { addLikeParticleBurst() } 1022 + triggerFavoriteToggle() 1023 + } label: { 1024 + Image(systemName: isFavorited ? "heart.fill" : "heart") 1025 + .font(.title3) 1026 + .foregroundStyle(isFavorited ? Color("AccentColor") : .white) 1027 + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 1028 + } 1029 + .overlay { 1030 + ForEach(likeParticleBursts, id: \.self) { _ in 1031 + ForEach(0 ..< 5) { i in 1032 + LikeParticleView(index: i) 1033 + } 1034 + } 1035 + } 1036 + } 1037 + .padding(.horizontal, 16) 1038 + .padding(.bottom, 16) 1039 + } 1040 + 1041 + private func doubleTapLike(at point: CGPoint) { 1042 + hearts.append(HeartAnimationState(position: point)) 1043 + addLikeParticleBurst() 1044 + guard !isFavorited, !isFavoriting else { return } 1045 + triggerFavoriteToggle() 1046 + } 1047 + 1048 + private func addLikeParticleBurst() { 1049 + let id = UUID() 1050 + likeParticleBursts.append(id) 1051 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 1052 + likeParticleBursts.removeAll { $0 == id } 1053 + } 1054 + } 1055 + 1056 + private func triggerFavoriteToggle() { 1057 + isFavoriting = true 1058 + Task { 1059 + await toggleStoryFavorite() 1060 + isFavoriting = false 1061 + } 1062 + } 1063 + 1064 + private func toggleStoryFavorite() async { 1065 + guard let authContext = await auth.authContext(), 1066 + currentStoryIndex < stories.count else { return } 1067 + 1068 + let story = stories[currentStoryIndex] 1069 + if let favUri = story.viewer?.fav { 1070 + // Unfavorite — optimistic 1071 + stories[currentStoryIndex].viewer?.fav = nil 1072 + let rkey = favUri.split(separator: "/").last.map(String.init) ?? "" 1073 + do { 1074 + try await client.deleteRecord(collection: "social.grain.favorite", rkey: rkey, auth: authContext) 1075 + } catch { 1076 + stories[currentStoryIndex].viewer?.fav = favUri 1077 + } 1078 + } else { 1079 + // Favorite — optimistic 1080 + let prevViewer = stories[currentStoryIndex].viewer 1081 + stories[currentStoryIndex].viewer = StoryViewerState(fav: "pending") 1082 + let record = AnyCodable([ 1083 + "subject": story.uri, 1084 + "createdAt": DateFormatting.nowISO(), 1085 + ]) 1086 + let repo = TokenStorage.userDID ?? "" 1087 + do { 1088 + let response = try await client.createRecord(collection: "social.grain.favorite", repo: repo, record: record, auth: authContext) 1089 + stories[currentStoryIndex].viewer = StoryViewerState(fav: response.uri) 1090 + } catch { 1091 + stories[currentStoryIndex].viewer = prevViewer 1092 + } 1093 + } 1094 + } 907 1095 } 908 1096 909 1097 /// Extracted so progress ticks only redraw this view, not the entire StoryViewer ··· 950 1138 .environment(AuthManager()) 951 1139 .environment(LabelDefinitionsCache()) 952 1140 .environment(ViewedStoryStorage()) 1141 + .environment(StoryStatusCache()) 953 1142 }