~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
272 lines · 10.3 KB Raw History
import os
import SwiftUI
import UIKit

private let spLogger = Logger(subsystem: "social.grain.grain", category: "StoryCommentPresenter")
private let spSignposter = OSSignposter(subsystem: "social.grain.grain", category: "StoryCommentPresenter")

// MARK: - Sheet target

/// Payload for the comment sheet. `id` changes per `open()` call so SwiftUI's
/// `.sheet(item:)` treats each presentation as a new item.
struct CommentSheetTarget: Identifiable, Equatable {
    let id = UUID()
    let storyUri: String
    let focusInput: Bool
    let viewModel: StoryCommentsViewModel
    let client: XRPCClient
    var onProfileTap: ((String) -> Void)?

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }
}

// MARK: - State holder (backing store for the dedicated window's root view)

@Observable
@MainActor
private final class CommentSheetWindowState {
    var target: CommentSheetTarget?
    @ObservationIgnored var onDismissed: (() -> Void)?
    /// Tracks which code path first cleared `target` so the dismiss log can
    /// distinguish swipe-down (default) from dim-tap, Done-button, and
    /// `close()`. Cleared inside `.sheet` `onDismiss`.
    @ObservationIgnored var pendingDismissSource: String?
}

// MARK: - Root view of the dedicated window

/// Lives inside the comment window's `UIHostingController`. It's a transparent
/// container with a SwiftUI `.sheet(item:)` modifier — all presentation,
/// animation, swipe-dismiss, and keyboard handling is native SwiftUI. The
/// manual dim is necessary because iOS's sheet dims its own window's
/// background, and ours is transparent.
private struct CommentSheetHostView: View {
    let auth: AuthManager
    let storyStatusCache: StoryStatusCache
    let viewedStories: ViewedStoryStorage
    @Bindable var state: CommentSheetWindowState

    var body: some View {
        ZStack {
            if state.target != nil {
                Color.black.opacity(0.4)
                    .ignoresSafeArea()
                    .contentShape(Rectangle())
                    .onTapGesture {
                        spSignposter.emitEvent("dim.tap")
                        state.pendingDismissSource = "dim.tap"
                        state.target = nil
                    }
                    .transition(.opacity)
            }
        }
        // Match the sheet's native dismissal duration so the dim fade finishes
        // at the same moment the sheet finishes sliding off screen. If it ends
        // earlier we get a stale transparent frame; if later, the window hide
        // in `onDismiss` truncates it and the user sees a pop.
        .animation(.easeInOut(duration: 0.35), value: state.target != nil)
        .sheet(item: $state.target, onDismiss: {
            let source = state.pendingDismissSource ?? "swipe"
            state.pendingDismissSource = nil
            spLogger.info("[sheet.onDismiss] source=\(source)")
            spSignposter.emitEvent("sheet.onDismiss", "source=\(source)")
            let cb = state.onDismissed
            state.onDismissed = nil
            cb?()
        }) { target in
            StoryCommentSheet(
                viewModel: target.viewModel,
                storyUri: target.storyUri,
                client: target.client,
                focusInput: target.focusInput,
                onProfileTap: target.onProfileTap,
                onDismiss: {
                    state.pendingDismissSource = "content.onDismiss"
                    state.target = nil
                }
            )
            .environment(auth)
            .environment(storyStatusCache)
            .environment(viewedStories)
            .presentationDetents([.medium])
            // Disables iOS's own dim + the default "tap dim to expand detent"
            // behavior. We draw our own dim above (tappable to dismiss), and
            // this also lets background taps reach it.
            .presentationBackgroundInteraction(.enabled)
        }
    }
}

// MARK: - Presenter

/// Presents the story comment sheet via SwiftUI's native `.sheet` modifier,
/// hosted inside a dedicated `UIWindow` at `.alert - 1`. UIKit's role is
/// limited to providing the isolated window — everything the user sees and
/// interacts with is standard SwiftUI. The window keeps the main window's VC
/// hierarchy (StoryViewer's fullScreenCover host) untouched by the sheet
/// presentation's lifecycle, which was causing SwiftUI to rebuild StoryViewer
/// with fresh `@State` on every open/close.
///
/// Presentation state is intentionally not observation-tracked: callers drive
/// their own local `@State` mirror via the `onDidClose` callback passed to
/// `open()`. Reading presentation state from a view body would re-run the
/// body on every open/close.
@Observable
@MainActor
final class StoryCommentPresenter {
    @ObservationIgnored var presentedStoryUri: String?

    @ObservationIgnored private weak var authManager: AuthManager?
    @ObservationIgnored private weak var storyStatusCache: StoryStatusCache?
    @ObservationIgnored private weak var viewedStories: ViewedStoryStorage?

    @ObservationIgnored private var commentWindow: UIWindow?
    @ObservationIgnored private let state = CommentSheetWindowState()
    @ObservationIgnored private var openSignpostState: OSSignpostIntervalState?

    func configure(
        auth: AuthManager,
        storyStatusCache: StoryStatusCache,
        viewedStories: ViewedStoryStorage
    ) {
        authManager = auth
        self.storyStatusCache = storyStatusCache
        self.viewedStories = viewedStories
    }

    func open(
        storyUri: String,
        focusInput: Bool,
        commentsViewModel: StoryCommentsViewModel,
        client: XRPCClient,
        onProfileTap: ((String) -> Void)? = nil,
        onDidClose: (() -> Void)? = nil
    ) {
        guard state.target == nil else {
            spLogger.info("[open] SKIPPED — target already set")
            spSignposter.emitEvent("open.skipped", "reason=target-set")
            return
        }
        guard state.onDismissed == nil else {
            spLogger.info("[open] SKIPPED — dismiss in progress")
            spSignposter.emitEvent("open.skipped", "reason=dismiss-in-progress")
            return
        }
        guard let auth = authManager,
              let statusCache = storyStatusCache,
              let viewed = viewedStories
        else {
            spLogger.info("[open] SKIPPED — env missing")
            spSignposter.emitEvent("open.skipped", "reason=env-missing")
            return
        }
        guard let scene = Self.foregroundActiveScene() else {
            spLogger.info("[open] ABORT — no foreground-active UIWindowScene")
            spSignposter.emitEvent("open.aborted", "reason=no-scene")
            return
        }

        let intervalState = spSignposter.beginInterval("open", "focusInput=\(focusInput)")
        openSignpostState = intervalState
        spLogger.info("[open] storyUri=\(storyUri) focusInput=\(focusInput)")

        // Lazy window creation — built once per app session and reused. The
        // root view's state/bindings persist across open/close cycles.
        ensureWindow(
            auth: auth,
            statusCache: statusCache,
            viewed: viewed,
            scene: scene
        )

        commentWindow?.isHidden = false
        commentWindow?.makeKeyAndVisible()
        spSignposter.emitEvent("open.window-visible")

        presentedStoryUri = storyUri

        // Wire the dismissal callback BEFORE setting the target so SwiftUI's
        // onDismiss closure sees the correct handler.
        state.onDismissed = { [weak self] in
            guard let self else { return }
            spLogger.info("[onDismissed] begin")
            spSignposter.emitEvent("onDismissed.begin")

            presentedStoryUri = nil

            // Hiding the comment window (or calling endEditing on it) crashes
            // when done synchronously inside SwiftUI's .sheet onDismiss:
            // resigning the @FocusState-bound TextField mid-sheet-teardown
            // re-enters SwiftUI while the sheet view tree is still unwinding.
            // Defer both to the next main-runloop tick so the current dismiss
            // callback returns first. Do NOT inline these — the crash is
            // easy to re-introduce.
            let windowToHide = commentWindow
            DispatchQueue.main.async {
                windowToHide?.endEditing(true)
                windowToHide?.isHidden = true
                spSignposter.emitEvent("onDismissed.window-hidden")
            }

            if let intervalState = openSignpostState {
                spSignposter.endInterval("open", intervalState)
                openSignpostState = nil
            }
            onDidClose?()
            spSignposter.emitEvent("onDismissed.end")
        }

        state.target = CommentSheetTarget(
            storyUri: storyUri,
            focusInput: focusInput,
            viewModel: commentsViewModel,
            client: client,
            onProfileTap: onProfileTap
        )
        spLogger.info("[open] end — state.target set")
        spSignposter.emitEvent("open.target-set")
    }

    func close() {
        spLogger.info("[close] programmatic close")
        spSignposter.emitEvent("close.programmatic")
        state.pendingDismissSource = "close.programmatic"
        state.target = nil
    }

    private func ensureWindow(
        auth: AuthManager,
        statusCache: StoryStatusCache,
        viewed: ViewedStoryStorage,
        scene: UIWindowScene
    ) {
        guard commentWindow == nil else { return }

        let root = CommentSheetHostView(
            auth: auth,
            storyStatusCache: statusCache,
            viewedStories: viewed,
            state: state
        )
        let host = UIHostingController(rootView: root)
        host.view.backgroundColor = .clear

        let window = UIWindow(windowScene: scene)
        window.windowLevel = UIWindow.Level.alert - 1
        window.rootViewController = host
        window.backgroundColor = .clear
        window.isHidden = true

        commentWindow = window
        spLogger.info("[ensureWindow] created at level \(window.windowLevel.rawValue)")
        spSignposter.emitEvent("ensureWindow.created")
    }

    private static func foregroundActiveScene() -> UIWindowScene? {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .first { $0.activationState == .foregroundActive }
    }
}