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: present story comment sheet in dedicated UIWindow

Hosts the comment sheet in an isolated UIWindow via StoryCommentPresenter
so SwiftUI no longer rebuilds StoryViewer's @State on every sheet open or
close. Adds StoryTimer.resume() to continue from existing progress after
sheet dismissal, combines the location pill and latest comment into a
single row, and simplifies the comment button to a bare bubble icon.

+383 -154
+242
Grain/ViewModels/StoryCommentPresenter.swift
··· 1 + import os 2 + import SwiftUI 3 + import UIKit 4 + 5 + private let spLogger = Logger(subsystem: "social.grain.grain", category: "StoryCommentPresenter") 6 + private let spSignposter = OSSignposter(subsystem: "social.grain.grain", category: "StoryCommentPresenter") 7 + 8 + // MARK: - Sheet target 9 + 10 + /// Payload for the comment sheet. `id` changes per `open()` call so SwiftUI's 11 + /// `.sheet(item:)` treats each presentation as a new item. 12 + struct CommentSheetTarget: Identifiable, Equatable { 13 + let id = UUID() 14 + let storyUri: String 15 + let focusInput: Bool 16 + let viewModel: StoryCommentsViewModel 17 + let client: XRPCClient 18 + var onProfileTap: ((String) -> Void)? 19 + 20 + static func == (lhs: Self, rhs: Self) -> Bool { 21 + lhs.id == rhs.id 22 + } 23 + } 24 + 25 + // MARK: - State holder (backing store for the dedicated window's root view) 26 + 27 + @Observable 28 + @MainActor 29 + private final class CommentSheetWindowState { 30 + var target: CommentSheetTarget? 31 + @ObservationIgnored var onDismissed: (() -> Void)? 32 + } 33 + 34 + // MARK: - Root view of the dedicated window 35 + 36 + /// Lives inside the comment window's `UIHostingController`. It's a transparent 37 + /// container with a SwiftUI `.sheet(item:)` modifier — all presentation, 38 + /// animation, swipe-dismiss, and keyboard handling is native SwiftUI. The 39 + /// manual dim is necessary because iOS's sheet dims its own window's 40 + /// background, and ours is transparent. 41 + private struct CommentSheetHostView: View { 42 + let auth: AuthManager 43 + let storyStatusCache: StoryStatusCache 44 + let viewedStories: ViewedStoryStorage 45 + @Bindable var state: CommentSheetWindowState 46 + 47 + var body: some View { 48 + ZStack { 49 + if state.target != nil { 50 + Color.black.opacity(0.4) 51 + .ignoresSafeArea() 52 + .contentShape(Rectangle()) 53 + .onTapGesture { 54 + spLogger.info("[dim.tap] dismissing via background tap") 55 + spSignposter.emitEvent("dim.tap") 56 + state.target = nil 57 + } 58 + .transition(.opacity) 59 + } 60 + } 61 + // Match the sheet's native dismissal duration so the dim fade finishes 62 + // at the same moment the sheet finishes sliding off screen. If it ends 63 + // earlier we get a stale transparent frame; if later, the window hide 64 + // in `onDismiss` truncates it and the user sees a pop. 65 + .animation(.easeInOut(duration: 0.35), value: state.target != nil) 66 + .sheet(item: $state.target, onDismiss: { 67 + spLogger.info("[sheet.onDismiss] fired") 68 + spSignposter.emitEvent("sheet.onDismiss") 69 + let cb = state.onDismissed 70 + state.onDismissed = nil 71 + cb?() 72 + }) { target in 73 + StoryCommentSheet( 74 + viewModel: target.viewModel, 75 + storyUri: target.storyUri, 76 + client: target.client, 77 + focusInput: target.focusInput, 78 + onProfileTap: target.onProfileTap, 79 + onDismiss: { 80 + spLogger.info("[sheet.content] onDismiss closure called") 81 + state.target = nil 82 + } 83 + ) 84 + .environment(auth) 85 + .environment(storyStatusCache) 86 + .environment(viewedStories) 87 + // Disables iOS's own dim + the default "tap dim to expand detent" 88 + // behavior. We draw our own dim above (tappable to dismiss), and 89 + // this also lets background taps reach it. 90 + .presentationBackgroundInteraction(.enabled) 91 + } 92 + } 93 + } 94 + 95 + // MARK: - Presenter 96 + 97 + /// Presents the story comment sheet via SwiftUI's native `.sheet` modifier, 98 + /// hosted inside a dedicated `UIWindow` at `.alert - 1`. UIKit's role is 99 + /// limited to providing the isolated window — everything the user sees and 100 + /// interacts with is standard SwiftUI. The window keeps the main window's VC 101 + /// hierarchy (StoryViewer's fullScreenCover host) untouched by the sheet 102 + /// presentation's lifecycle, which was causing SwiftUI to rebuild StoryViewer 103 + /// with fresh `@State` on every open/close. 104 + /// 105 + /// Presentation state is intentionally not observation-tracked: callers drive 106 + /// their own local `@State` mirror via the `onDidClose` callback passed to 107 + /// `open()`. Reading presentation state from a view body would re-run the 108 + /// body on every open/close. 109 + @Observable 110 + @MainActor 111 + final class StoryCommentPresenter { 112 + @ObservationIgnored var presentedStoryUri: String? 113 + 114 + @ObservationIgnored private weak var authManager: AuthManager? 115 + @ObservationIgnored private weak var storyStatusCache: StoryStatusCache? 116 + @ObservationIgnored private weak var viewedStories: ViewedStoryStorage? 117 + 118 + @ObservationIgnored private var commentWindow: UIWindow? 119 + @ObservationIgnored private let state = CommentSheetWindowState() 120 + @ObservationIgnored private var openSignpostState: OSSignpostIntervalState? 121 + 122 + func configure( 123 + auth: AuthManager, 124 + storyStatusCache: StoryStatusCache, 125 + viewedStories: ViewedStoryStorage 126 + ) { 127 + authManager = auth 128 + self.storyStatusCache = storyStatusCache 129 + self.viewedStories = viewedStories 130 + } 131 + 132 + func open( 133 + storyUri: String, 134 + focusInput: Bool, 135 + commentsViewModel: StoryCommentsViewModel, 136 + client: XRPCClient, 137 + onProfileTap: ((String) -> Void)? = nil, 138 + onDidClose: (() -> Void)? = nil 139 + ) { 140 + guard state.target == nil else { 141 + spLogger.info("[open] SKIPPED — target already set") 142 + spSignposter.emitEvent("open.skipped", "reason=target-set") 143 + return 144 + } 145 + guard let auth = authManager, 146 + let statusCache = storyStatusCache, 147 + let viewed = viewedStories 148 + else { 149 + spLogger.info("[open] SKIPPED — env missing") 150 + spSignposter.emitEvent("open.skipped", "reason=env-missing") 151 + return 152 + } 153 + guard let scene = Self.foregroundActiveScene() else { 154 + spLogger.info("[open] ABORT — no foreground-active UIWindowScene") 155 + spSignposter.emitEvent("open.aborted", "reason=no-scene") 156 + return 157 + } 158 + 159 + let intervalState = spSignposter.beginInterval("open", "focusInput=\(focusInput)") 160 + openSignpostState = intervalState 161 + spLogger.info("[open] begin storyUri=\(storyUri) focusInput=\(focusInput)") 162 + 163 + // Lazy window creation — built once per app session and reused. The 164 + // root view's state/bindings persist across open/close cycles. 165 + ensureWindow( 166 + auth: auth, 167 + statusCache: statusCache, 168 + viewed: viewed, 169 + scene: scene 170 + ) 171 + 172 + commentWindow?.isHidden = false 173 + commentWindow?.makeKeyAndVisible() 174 + spSignposter.emitEvent("open.window-visible") 175 + 176 + presentedStoryUri = storyUri 177 + 178 + // Wire the dismissal callback BEFORE setting the target so SwiftUI's 179 + // onDismiss closure sees the correct handler. 180 + state.onDismissed = { [weak self] in 181 + guard let self else { return } 182 + spLogger.info("[onDismissed] running") 183 + presentedStoryUri = nil 184 + commentWindow?.isHidden = true 185 + if let intervalState = openSignpostState { 186 + spSignposter.endInterval("open", intervalState) 187 + openSignpostState = nil 188 + } 189 + onDidClose?() 190 + } 191 + 192 + state.target = CommentSheetTarget( 193 + storyUri: storyUri, 194 + focusInput: focusInput, 195 + viewModel: commentsViewModel, 196 + client: client, 197 + onProfileTap: onProfileTap 198 + ) 199 + spLogger.info("[open] end — state.target set") 200 + spSignposter.emitEvent("open.target-set") 201 + } 202 + 203 + func close() { 204 + spLogger.info("[close] programmatic close") 205 + spSignposter.emitEvent("close.programmatic") 206 + state.target = nil 207 + } 208 + 209 + private func ensureWindow( 210 + auth: AuthManager, 211 + statusCache: StoryStatusCache, 212 + viewed: ViewedStoryStorage, 213 + scene: UIWindowScene 214 + ) { 215 + guard commentWindow == nil else { return } 216 + 217 + let root = CommentSheetHostView( 218 + auth: auth, 219 + storyStatusCache: statusCache, 220 + viewedStories: viewed, 221 + state: state 222 + ) 223 + let host = UIHostingController(rootView: root) 224 + host.view.backgroundColor = .clear 225 + 226 + let window = UIWindow(windowScene: scene) 227 + window.windowLevel = UIWindow.Level.alert - 1 228 + window.rootViewController = host 229 + window.backgroundColor = .clear 230 + window.isHidden = true 231 + 232 + commentWindow = window 233 + spLogger.info("[ensureWindow] created at level \(window.windowLevel.rawValue)") 234 + spSignposter.emitEvent("ensureWindow.created") 235 + } 236 + 237 + private static func foregroundActiveScene() -> UIWindowScene? { 238 + UIApplication.shared.connectedScenes 239 + .compactMap { $0 as? UIWindowScene } 240 + .first { $0.activationState == .foregroundActive } 241 + } 242 + }
+9
Grain/Views/MainTabView.swift
··· 7 7 struct MainTabView: View { 8 8 @Environment(AuthManager.self) private var auth 9 9 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 10 + @Environment(StoryStatusCache.self) private var storyStatusCache 11 + @Environment(ViewedStoryStorage.self) private var viewedStories 10 12 @Environment(\.scenePhase) private var scenePhase 11 13 @State private var selectedTab: AppTab = .feed 14 + @State private var commentPresenter = StoryCommentPresenter() 12 15 @State private var client = XRPCClient(baseURL: AuthManager.serverURL) 13 16 @State private var showCreate = false 14 17 @State private var avatarTabImage: UIImage? ··· 69 72 } 70 73 } 71 74 .tint(Color("AccentColor")) 75 + .environment(commentPresenter) 72 76 .task { 77 + commentPresenter.configure( 78 + auth: auth, 79 + storyStatusCache: storyStatusCache, 80 + viewedStories: viewedStories 81 + ) 73 82 let c = auth.makeClient() 74 83 client = c 75 84 notificationsVM.updateClient(c)
+132 -154
Grain/Views/Stories/StoryViewer.swift
··· 6 6 private let svLogger = Logger(subsystem: "social.grain.grain", category: "StoryViewer") 7 7 private let svSignposter = OSSignposter(subsystem: "social.grain.grain", category: "StoryViewer") 8 8 9 + @MainActor private var svInstanceCounter: Int = 0 10 + @MainActor private func svNextInstanceID() -> Int { 11 + svInstanceCounter += 1 12 + return svInstanceCounter 13 + } 14 + 9 15 @Observable 10 16 @MainActor 11 17 private final class StoryTimer { ··· 17 23 func start() { 18 24 svLogger.info("[timer.start] called") 19 25 svSignposter.emitEvent("timer.start") 20 - stop() 21 26 progress = 0 22 - isRunning = true 23 27 quarterFired = false 28 + run(fromProgress: 0) 29 + } 30 + 31 + func resume() { 32 + guard !isRunning else { return } 33 + guard progress < 1.0 else { start(); return } 34 + svLogger.info("[timer.resume] called progress=\(progress)") 35 + svSignposter.emitEvent("timer.resume", "progress=\(progress)") 36 + run(fromProgress: progress) 37 + } 38 + 39 + private func run(fromProgress start: CGFloat) { 40 + stop() 41 + isRunning = true 42 + let tickInterval: TimeInterval = 0.05 43 + let totalTicks = Int(duration / tickInterval) 44 + let startTick = max(Int(start * CGFloat(totalTicks)), 0) 24 45 task = Task { 25 - let tickInterval: TimeInterval = 0.05 26 - let totalTicks = Int(duration / tickInterval) 27 - for tick in 0 ... totalTicks { 46 + for tick in startTick ... totalTicks { 28 47 do { 29 48 try await Task.sleep(for: .milliseconds(Int(tickInterval * 1000))) 30 49 } catch { return } ··· 75 94 @Environment(ViewedStoryStorage.self) private var viewedStories 76 95 @Environment(StoryStatusCache.self) private var storyStatusCache 77 96 @Environment(StoryFavoriteCache.self) private var storyFavoriteCache 97 + @Environment(StoryCommentPresenter.self) private var commentPresenter 78 98 let authors: [GrainStoryAuthor] 79 99 let client: XRPCClient 80 100 var onProfileTap: ((String) -> Void)? ··· 104 124 // MARK: - Comments & Likes 105 125 106 126 @State private var commentsViewModel: StoryCommentsViewModel 107 - @State private var showCommentSheet = false 108 - @State private var commentSheetFocusInput = false 109 - @State private var sheetStoryUri: String? 127 + /// Local mirror of the presenter's sheet state. Driven by the `onDidClose` 128 + /// callback passed to `commentPresenter.open(...)`. Do NOT replace this 129 + /// with a read of `commentPresenter.presentedStoryUri` — that read would 130 + /// re-evaluate the body on every open/close and cascade into a storyContent 131 + /// re-render (black flash on sheet transitions). 132 + @State private var isCommentSheetOpen = false 110 133 @State private var hasLoadedInitialStories = false 111 134 @State private var hearts: [HeartAnimationState] = [] 112 135 @State private var isFavoriting = false 113 136 @State private var likeParticleBursts: [UUID] = [] 137 + @State private var instanceID: Int = 0 114 138 115 139 init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, initialStories: [GrainStory]? = nil, startStoryIndex: Int? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 116 140 self.authors = authors ··· 127 151 _currentStoryIndex = State(initialValue: startStoryIndex) 128 152 } 129 153 } 154 + let id = svNextInstanceID() 155 + _instanceID = State(initialValue: id) 156 + svLogger.info("[init] StoryViewer.init id=\(id) startAuthorDid=\(startAuthorDid ?? "nil") authors.count=\(authors.count)") 130 157 } 131 158 132 159 private var currentStory: GrainStory? { ··· 147 174 } 148 175 149 176 var body: some View { 150 - let _ = svLogger.debug("[body] eval showCommentSheet=\(showCommentSheet) storiesCount=\(stories.count) currentIdx=\(currentStoryIndex) imageLoaded=\(imageLoaded)") 177 + let _ = svLogger.info("[body] eval id=\(instanceID) storiesCount=\(stories.count) currentIdx=\(currentStoryIndex) imageLoaded=\(imageLoaded) hasLoaded=\(hasLoadedInitialStories)") 151 178 return ZStack { 152 179 if let pendingIdx = pendingTransition.authorIndex { 153 180 pendingFaceView(authorIdx: pendingIdx) ··· 167 194 .transition(.identity) 168 195 } 169 196 } 170 - .onAppear { svLogger.info("[body] onAppear") } 171 - .onDisappear { svLogger.info("[body] onDisappear") } 197 + .onAppear { svLogger.info("[body] onAppear id=\(instanceID)") } 198 + .onDisappear { svLogger.info("[body] onDisappear id=\(instanceID)") } 172 199 .clipped() 173 200 .background(Color.black.ignoresSafeArea()) 174 201 .background( ··· 182 209 onHorizontalDragStart: { forward in beginSwipe(forward: forward) }, 183 210 onSwipeDragging: { tx in updateSwipeDrag(tx) }, 184 211 onHorizontalDragCancel: { cancelSwipe() }, 185 - isEnabled: !showCommentSheet 212 + isEnabled: !isCommentSheetOpen 186 213 ) 187 214 ) 188 215 .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { ··· 202 229 .onChange(of: reportTarget?.uri) { 203 230 if reportTarget == nil { timer.start() } 204 231 } 205 - .sheet(isPresented: $showCommentSheet) { 206 - let _ = svLogger.info("[sheet.content] body eval sheetStoryUri=\(sheetStoryUri ?? "nil") currentStoryURI=\(currentStory?.uri ?? "nil")") 207 - if let uri = sheetStoryUri { 208 - StoryCommentSheet( 209 - viewModel: commentsViewModel, 210 - storyUri: uri, 211 - client: client, 212 - focusInput: commentSheetFocusInput, 213 - onProfileTap: { did in 214 - showCommentSheet = false 215 - close() 216 - onProfileTap?(did) 217 - }, 218 - onDismiss: { showCommentSheet = false } 219 - ) 220 - .environment(auth) 221 - .environment(storyStatusCache) 222 - .environment(viewedStories) 223 - .onAppear { svLogger.info("[sheet.content] onAppear uri=\(uri)") } 224 - .onDisappear { svLogger.info("[sheet.content] onDisappear") } 225 - } else { 226 - let _ = svLogger.info("[sheet.content] EMPTY (sheetStoryUri nil)") 227 - } 228 - } 229 - .onChange(of: showCommentSheet) { old, new in 230 - svLogger.info("[onChange showCommentSheet] \(old) → \(new) sheetStoryUri=\(sheetStoryUri ?? "nil")") 231 - } 232 232 .onChange(of: imageLoaded) { old, new in 233 233 svLogger.info("[onChange imageLoaded] \(old) → \(new)") 234 234 } ··· 239 239 svLogger.info("[onChange stories.count] \(old) → \(new)") 240 240 } 241 241 .task { 242 - svLogger.info("[task] fired hasLoadedInitialStories=\(hasLoadedInitialStories) showCommentSheet=\(showCommentSheet)") 243 - svSignposter.emitEvent("task.fired", "hasLoaded=\(hasLoadedInitialStories),sheetOpen=\(showCommentSheet)") 242 + svLogger.info("[task] fired id=\(instanceID) hasLoadedInitialStories=\(hasLoadedInitialStories)") 243 + svSignposter.emitEvent("task.fired", "id=\(instanceID) hasLoaded=\(hasLoadedInitialStories)") 244 244 // Guard against re-runs: .task can re-fire when the view re-enters the 245 245 // hierarchy (e.g. after sheet presentation cycles), and we only want to 246 246 // load stories once per StoryViewer instance. ··· 349 349 .padding(.vertical, 8) 350 350 351 351 Spacer().allowsHitTesting(false) 352 - 353 - if let story, let locationText = storyLocationText(story) { 354 - HStack { 355 - HStack(spacing: 4) { 356 - Image(systemName: "location.fill") 357 - Text(locationText) 358 - } 359 - .font(.caption) 360 - .foregroundStyle(.white) 361 - .padding(.horizontal, 12) 362 - .padding(.vertical, 6) 363 - .background(.ultraThinMaterial, in: Capsule()) 364 - Spacer() 365 - } 366 - .padding(.horizontal) 367 - .padding(.bottom, 32) 368 - } 369 352 } 370 353 } 371 354 } ··· 416 399 // Text("fullsize · cache").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 417 400 // } 418 401 .onAppear { 419 - svLogger.info("[image.onAppear cached-fullsize] imageLoaded=\(imageLoaded) showCommentSheet=\(showCommentSheet)") 402 + svLogger.info("[image.onAppear cached-fullsize] imageLoaded=\(imageLoaded)") 420 403 if !imageLoaded { 421 404 imageLoaded = true 422 405 startTimerIfSafe() ··· 439 422 // Text("fullsize · network").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 440 423 // } 441 424 .onAppear { 442 - svLogger.info("[image.onAppear lazy-fullsize] imageLoaded=\(imageLoaded) showCommentSheet=\(showCommentSheet)") 425 + svLogger.info("[image.onAppear lazy-fullsize] imageLoaded=\(imageLoaded)") 443 426 if !imageLoaded { 444 427 imageLoaded = true 445 428 startTimerIfSafe() ··· 518 501 .frame(height: 80) 519 502 .allowsHitTesting(false) 520 503 } 521 - .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && !showCommentSheet && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 504 + .allowsHitTesting(reportTarget == nil && !showDeleteConfirm && !isCommentSheetOpen && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 522 505 523 506 // Double-tap heart animations 524 507 ForEach(hearts) { heart in ··· 598 581 Spacer() 599 582 .allowsHitTesting(false) 600 583 601 - if let story, let locationText = storyLocationText(story) { 602 - HStack { 603 - HStack(spacing: 4) { 604 - Image(systemName: showLocationCopied ? "checkmark" : "location.fill") 605 - Text(showLocationCopied ? "Copied" : locationText) 606 - } 607 - .font(.caption) 608 - .foregroundStyle(.white) 609 - .padding(.horizontal, 12) 610 - .padding(.vertical, 6) 611 - .background(.ultraThinMaterial, in: Capsule()) 612 - .contentTransition(.symbolEffect(.replace)) 613 - .id(story.uri) 614 - .onTapGesture { 615 - UIPasteboard.general.string = locationText 616 - withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = true } 617 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 618 - withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = false } 619 - } 620 - } 621 - Spacer() 622 - } 623 - .padding(.horizontal) 624 - .padding(.bottom, 8) 625 - } 626 - 627 584 // MARK: Comment preview + input bar 628 585 629 - if currentStory != nil { 630 - // Latest comment preview 631 - if let latest = commentsViewModel.latestComment { 632 - Button { 633 - openCommentSheet(focusInput: false) 634 - } label: { 635 - HStack(spacing: 6) { 636 - AvatarView(url: latest.author.avatar, size: 20, animated: false) 637 - Text("**\(latest.author.displayName ?? latest.author.handle)** \(latest.text)") 638 - .font(.caption) 639 - .foregroundStyle(.white) 640 - .lineLimit(1) 641 - Spacer(minLength: 0) 586 + 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) 642 605 } 643 - .padding(.horizontal) 644 - .padding(.bottom, 4) 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 + } 625 + } 626 + } 645 627 } 646 - .buttonStyle(.plain) 628 + .padding(.horizontal, 16) 629 + .padding(.bottom, 4) 647 630 } 648 631 649 632 storyInputBar ··· 662 645 private func canNavigate() -> Bool { 663 646 !isLoadingStories && !stories.isEmpty 664 647 && reportTarget == nil && !showDeleteConfirm 665 - && !showCommentSheet 648 + && !isCommentSheetOpen 666 649 && !isDragging 667 650 && Date().timeIntervalSince(lastNavTime) > 0.3 668 651 } ··· 672 655 svLogger.info("[startTimerIfSafe] skipped (imageLoaded=false)") 673 656 return 674 657 } 658 + guard !isCommentSheetOpen else { 659 + svLogger.info("[startTimerIfSafe] skipped (sheet open)") 660 + return 661 + } 675 662 let action = storyLabelResult.action 676 - svLogger.info("[startTimerIfSafe] action=\(String(describing: action)) showCommentSheet=\(showCommentSheet)") 663 + svLogger.info("[startTimerIfSafe] action=\(String(describing: action))") 677 664 if action == .none || action == .badge { timer.start() } 665 + } 666 + 667 + private func resumeTimerIfSafe() { 668 + guard imageLoaded else { 669 + svLogger.info("[resumeTimerIfSafe] skipped (imageLoaded=false)") 670 + return 671 + } 672 + guard !isCommentSheetOpen else { 673 + svLogger.info("[resumeTimerIfSafe] skipped (sheet open)") 674 + return 675 + } 676 + let action = storyLabelResult.action 677 + svLogger.info("[resumeTimerIfSafe] action=\(String(describing: action)) progress=\(timer.progress)") 678 + if action == .none || action == .badge { timer.resume() } 678 679 } 679 680 680 681 private func isFullsizeCached(_ story: GrainStory?) -> Bool { ··· 926 927 } 927 928 928 929 private func presentStories(_ fetched: [GrainStory], resumeIndex: Int? = nil) { 929 - svLogger.info("[presentStories] enter count=\(fetched.count) resumeIndex=\(resumeIndex ?? -1) showCommentSheet=\(showCommentSheet)") 930 - svSignposter.emitEvent("presentStories.enter", "count=\(fetched.count),sheetOpen=\(showCommentSheet)") 930 + svLogger.info("[presentStories] enter count=\(fetched.count) resumeIndex=\(resumeIndex ?? -1)") 931 + svSignposter.emitEvent("presentStories.enter", "count=\(fetched.count)") 931 932 let targetIndex: Int 932 933 if let resume = resumeIndex { 933 934 targetIndex = min(resume, max(fetched.count - 1, 0)) ··· 1052 1053 1053 1054 // MARK: - Comments & Likes 1054 1055 1055 - /// Open the comment sheet, pinning the URI at open time so the sheet 1056 - /// content doesn't depend on a live reading of `currentStory` (which can 1057 - /// flicker to nil during view re-renders). 1058 1056 private func openCommentSheet(focusInput: Bool) { 1059 1057 guard let uri = currentStory?.uri else { 1060 1058 svLogger.info("[openCommentSheet] SKIPPED (currentStory nil)") ··· 1063 1061 svLogger.info("[openCommentSheet] uri=\(uri) focusInput=\(focusInput)") 1064 1062 svSignposter.emitEvent("openCommentSheet", "focusInput=\(focusInput)") 1065 1063 timer.stop() 1066 - sheetStoryUri = uri 1067 - commentSheetFocusInput = focusInput 1068 - showCommentSheet = true 1064 + isCommentSheetOpen = true 1065 + let onProfileTap = onProfileTap 1066 + commentPresenter.open( 1067 + storyUri: uri, 1068 + focusInput: focusInput, 1069 + commentsViewModel: commentsViewModel, 1070 + client: client, 1071 + onProfileTap: { [commentPresenter, fadeDismissHandle] did in 1072 + commentPresenter.close() 1073 + fadeDismissHandle.fadeDismiss() 1074 + onProfileTap?(did) 1075 + }, 1076 + onDidClose: { 1077 + svSignposter.emitEvent("onDidClose") 1078 + isCommentSheetOpen = false 1079 + resumeTimerIfSafe() 1080 + } 1081 + ) 1069 1082 } 1070 1083 1071 1084 private var isFavorited: Bool { ··· 1079 1092 Button { 1080 1093 openCommentSheet(focusInput: false) 1081 1094 } label: { 1082 - VStack(spacing: 2) { 1083 - Image(systemName: "bubble.left") 1084 - .font(.body) 1085 - if commentsViewModel.totalCount > 0 { 1086 - Text("\(commentsViewModel.totalCount)") 1087 - .font(.caption2) 1088 - .monospacedDigit() 1089 - } 1090 - } 1091 - .foregroundStyle(.white) 1092 - .frame(width: 36) 1093 - .contentShape(Rectangle()) 1095 + Image(systemName: "bubble") 1096 + .font(.body) 1097 + .foregroundStyle(.white) 1098 + .frame(width: 36, height: 36) 1099 + .contentShape(Rectangle()) 1094 1100 } 1095 1101 .buttonStyle(.plain) 1096 1102 ··· 1245 1251 .environment(ViewedStoryStorage()) 1246 1252 .environment(StoryStatusCache()) 1247 1253 .environment(StoryFavoriteCache()) 1248 - } 1249 - 1250 - #Preview("Story Viewer + Comment Sheet") { 1251 - let vm = StoryCommentsViewModel(client: XRPCClient(baseURL: AuthManager.serverURL)) 1252 - vm.comments = PreviewData.storyComments 1253 - vm.latestComment = PreviewData.storyComments.first 1254 - vm.totalCount = PreviewData.storyComments.count 1255 - 1256 - return StoryViewer( 1257 - authors: PreviewData.storyAuthors, 1258 - startAuthorDid: "did:plc:prevuser1", 1259 - initialStories: PreviewData.stories, 1260 - client: XRPCClient(baseURL: AuthManager.serverURL) 1261 - ) 1262 - .environment(AuthManager()) 1263 - .environment(LabelDefinitionsCache()) 1264 - .environment(ViewedStoryStorage()) 1265 - .environment(StoryStatusCache()) 1266 - .environment(StoryFavoriteCache()) 1267 - .sheet(isPresented: .constant(true)) { 1268 - StoryCommentSheet( 1269 - viewModel: vm, 1270 - storyUri: PreviewData.stories[0].uri, 1271 - client: XRPCClient(baseURL: AuthManager.serverURL) 1272 - ) 1273 - .environment(AuthManager()) 1274 - .environment(StoryStatusCache()) 1275 - .environment(ViewedStoryStorage()) 1276 - } 1254 + .environment(StoryCommentPresenter()) 1277 1255 }