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

grain.social/grain-ios

1 collaborator
0
1
main
398 lines · 15.8 KB Raw History
import Foundation
import os

private let logger = Logger(subsystem: "social.grain.grain", category: "GalleryUpload")

/// Just enough of `AuthManager` for the uploader to get a token that may have
/// been refreshed since the last attempt — and enough of a seam to exercise the
/// publish state machine without a keychain.
@MainActor
protocol AuthContextProviding {
    func authContext() async -> AuthContext?
}

extension AuthManager: AuthContextProviding {}

/// Publishes gallery drafts, and picks up the ones an earlier attempt left behind.
///
/// The old flow issued roughly `2n + 2` record writes with server-assigned
/// record keys and no retries, so a single dropped request on a bad connection
/// both failed the post and stranded whatever had already been written. Tapping
/// Post again started over from nothing, which is how a gallery ended up in the
/// feed twice.
///
/// This runs in two steps instead. First the blobs go up, one request each,
/// checkpointed to disk as they land — those are content-addressed, so
/// re-uploading is harmless and skipping is free. Then every record goes out in
/// a single atomic `applyWrites` commit with record keys the draft has been
/// carrying since it was built. Retrying either step is safe, and a retry only
/// pays for the part that hadn't finished.
@MainActor
@Observable
final class GalleryUploadCenter {
    enum Stage: Equatable {
        case idle
        case preparing(completed: Int, total: Int)
        case uploading(completed: Int, total: Int)
        case publishing
        case finished
        case failed(message: String)

        var isBusy: Bool {
            switch self {
            case .preparing, .uploading, .publishing: true
            case .idle, .finished, .failed: false
            }
        }

        /// 0...1 for the determinate bar, or nil while the work isn't countable.
        var fraction: Double? {
            switch self {
            // Preparing and uploading are each roughly half the wait, and both
            // are per-photo, so they share one bar rather than resetting it.
            case let .preparing(completed, total):
                total > 0 ? Double(completed) / Double(total) * 0.5 : nil
            case let .uploading(completed, total):
                total > 0 ? 0.5 + Double(completed) / Double(total) * 0.5 : nil
            case .publishing: 1
            case .finished: 1
            case .idle, .failed: nil
            }
        }

        var label: String {
            switch self {
            case .idle: ""
            case let .preparing(completed, total): "Preparing photo \(min(completed + 1, total)) of \(total)"
            case let .uploading(completed, total): "Uploading photo \(min(completed + 1, total)) of \(total)"
            case .publishing: "Publishing gallery"
            case .finished: "Posted"
            case let .failed(message): message
            }
        }
    }

    private(set) var stage: Stage = .idle
    /// The draft being worked on, kept after a failure so `retry` has something
    /// to resume.
    private(set) var activeDraft: GalleryDraft?
    /// Drafts from an interrupted publish that nothing is currently working on.
    private(set) var pending: [GalleryDraft] = []

    /// Called after a *background* resume puts a gallery live, so the feed can
    /// refresh to show it.
    ///
    /// Deliberately not fired for a publish the create sheet is driving: that
    /// path has its own completion handler, and refreshing from both meant the
    /// feed was rebuilt while the sheet was still presented — which re-presented
    /// the sheet for a frame before it closed for good.
    var onPublished: (@MainActor () -> Void)?

    /// DID of the signed-in account. Drafts on disk name the repo they publish
    /// to, so this filters out galleries composed under a different account —
    /// resuming one of those would write to a repo we no longer hold a token
    /// for. Nil means "don't filter", which is what tests and previews want.
    var ownerDID: String?

    private let store: GalleryDraftStore
    private var inFlight: Task<Bool, Never>?

    init(store: GalleryDraftStore = .shared) {
        self.store = store
    }

    /// Re-point the uploader at another account. A draft belonging to the
    /// account being left stays on disk — it reappears when that account comes
    /// back — but it's taken off screen so the resume banner and its retry
    /// button can't act on it with the wrong credentials.
    func accountChanged(to did: String?) {
        guard did != ownerDID else { return }
        ownerDID = did
        if let draft = activeDraft, draft.repo != did {
            activeDraft = nil
            stage = .idle
        }
        refreshPending()
    }

    /// Re-read what's on disk. Cheap — the JSON is a few kilobytes.
    func refreshPending() {
        let activeID = activeDraft?.id
        pending = store.loadAll().filter { draft in
            draft.id != activeID && (ownerDID == nil || draft.repo == ownerDID)
        }
    }

    func discard(_ draftID: UUID) {
        store.delete(draftID)
        pending.removeAll { $0.id == draftID }
        if activeDraft?.id == draftID {
            activeDraft = nil
            stage = .idle
        }
    }

    /// Step away from a failed publish without throwing the work out — the
    /// draft stays on disk and gets picked up by `resumePending`.
    func setAside() {
        if let draft = activeDraft {
            activeDraft = nil
            pending.append(draft)
        }
        stage = .idle
    }

    func clearFailure() {
        if case .failed = stage {
            stage = .idle
        }
    }

    // MARK: - Entry points

    /// Prepare the photos on screen into a draft, then publish it.
    @discardableResult
    func publish(
        items: [PhotoItem],
        repo: String,
        title: String,
        description: String,
        labels: [String],
        location: GalleryDraft.Location?,
        includeExif: Bool,
        postToBluesky: Bool,
        client: XRPCClient,
        auth: any AuthContextProviding
    ) async -> Bool {
        stage = .preparing(completed: 0, total: items.count)
        do {
            let draft = try await GalleryDraft.build(
                items: items,
                repo: repo,
                title: title,
                description: description,
                labels: labels,
                location: location,
                includeExif: includeExif,
                postToBluesky: postToBluesky,
                store: store,
                onProgress: { [weak self] completed, total in
                    self?.stage = .preparing(completed: completed, total: total)
                }
            )
            return await publish(draft, client: client, auth: auth)
        } catch {
            logger.error("Preparing draft failed: \(error.localizedDescription)")
            stage = .failed(message: Self.message(for: error))
            return false
        }
    }

    /// Re-run the publish that just failed, resuming from its last checkpoint.
    @discardableResult
    func retry(client: XRPCClient, auth: any AuthContextProviding) async -> Bool {
        guard let draft = activeDraft else { return false }
        return await publish(draft, client: client, auth: auth)
    }

    /// Resume every leftover draft, one at a time. Safe to call on launch and
    /// on foreground — a draft already being worked on is skipped, and one
    /// that turns out to be published already is cleaned up by `publish`.
    func resumePending(client: XRPCClient, auth: any AuthContextProviding) async {
        guard !stage.isBusy else { return }
        refreshPending()
        var anyPublished = false
        for draft in pending {
            guard !stage.isBusy else { break }
            logger.info("Resuming interrupted gallery \(draft.id)")
            let published = await publish(draft, client: client, auth: auth)
            if !published {
                break
            }
            anyPublished = true
        }
        // Fired once at the end — every call rebuilds the feed.
        if anyPublished {
            onPublished?()
        }
    }

    /// Publish `draft`, or pick up where a previous attempt stopped.
    /// Returns true once the gallery is live.
    ///
    /// Publishes are serialised. A background resume and a tap on Post can
    /// arrive at the same moment, and two of these interleaving would have them
    /// trampling each other's `stage` and `activeDraft`.
    @discardableResult
    func publish(_ draft: GalleryDraft, client: XRPCClient, auth: any AuthContextProviding) async -> Bool {
        if let inFlight {
            _ = await inFlight.value
        }
        let task = Task { await performPublish(draft, client: client, auth: auth) }
        inFlight = task
        let published = await task.value
        if inFlight == task {
            inFlight = nil
        }
        return published
    }

    private func performPublish(_ draft: GalleryDraft, client: XRPCClient, auth: any AuthContextProviding) async -> Bool {
        guard let authContext = await auth.authContext() else {
            stage = .failed(message: "You're signed out. Sign in again to finish posting.")
            return false
        }

        var draft = draft
        activeDraft = draft
        pending.removeAll { $0.id == draft.id }
        store.save(draft)

        do {
            try await uploadBlobs(&draft, client: client, authContext: authContext)

            if !draft.committed {
                stage = .publishing
                try await commit(draft, client: client, authContext: authContext)
                draft.committed = true
                store.save(draft)
            }

            // The gallery is live from here on. The cross-post is a bonus, and
            // a failure in it must not send the user back to a retry screen for
            // a gallery that already posted.
            if draft.postToBluesky, !draft.crossPosted {
                await crossPostToBluesky(draft, client: client, authContext: authContext)
                draft.crossPosted = true
                store.save(draft)
            }

            store.delete(draft.id)
            activeDraft = nil
            stage = .finished
            refreshPending()
            return true
        } catch {
            logger.error("Publish failed for \(draft.id): \(error.localizedDescription)")
            // Keep the draft so retrying resumes instead of re-uploading
            // everything. Read it back from disk rather than trusting the local
            // copy: the checkpoint written after the last successful upload is
            // what a fresh launch would resume from, so a retry should start
            // from exactly the same place.
            activeDraft = store.load(draft.id) ?? draft
            stage = .failed(message: Self.message(for: error))
            refreshPending()
            return false
        }
    }

    // MARK: - Steps

    private func uploadBlobs(_ draft: inout GalleryDraft, client: XRPCClient, authContext: AuthContext) async throws {
        let total = draft.photos.count
        stage = .uploading(completed: draft.uploadedPhotoCount, total: total)

        for index in draft.photos.indices where draft.photos[index].blob == nil {
            let photo = draft.photos[index]
            let data = try store.readImage(draftID: draft.id, fileName: photo.fileName)
            let response = try await NetworkRetry.run {
                try await client.uploadBlob(data: data, mimeType: "image/jpeg", auth: authContext)
            }
            draft.photos[index].blob = response.blob
            // Checkpoint per photo: an upload that dies on 14 of 20 resumes at 14.
            store.save(draft)
            stage = .uploading(completed: draft.uploadedPhotoCount, total: total)
        }
    }

    private func commit(_ draft: GalleryDraft, client: XRPCClient, authContext: AuthContext) async throws {
        do {
            _ = try await NetworkRetry.run {
                try await client.applyWrites(draft.writes(), auth: authContext)
            }
        } catch {
            // The commit is atomic, but "we never saw the response" and "the
            // commit landed" look identical from here — as does a retry that
            // was rejected because the records already exist. All three mean
            // the gallery is on the PDS. Ask before calling it a failure.
            if await galleryExists(draft, client: client, authContext: authContext) {
                logger.info("Commit reported \(error.localizedDescription), but the gallery is on the PDS — treating as published")
                return
            }
            throw error
        }
    }

    private func galleryExists(_ draft: GalleryDraft, client: XRPCClient, authContext: AuthContext) async -> Bool {
        do {
            let response = try await NetworkRetry.run(attempts: 2) {
                try await client.getRecord(uri: draft.galleryUri, auth: authContext)
            }
            return response.uri != nil
        } catch {
            // Couldn't tell. Report the original failure and let the retry ask again.
            return false
        }
    }

    private func crossPostToBluesky(_ draft: GalleryDraft, client: XRPCClient, authContext: AuthContext) async {
        let images = draft.photos.compactMap { photo -> (blob: BlobRef, alt: String, width: Int, height: Int)? in
            guard let blob = photo.blob else { return nil }
            return (blob: blob, alt: photo.alt, width: photo.width, height: photo.height)
        }
        do {
            try await BlueskyPost.create(
                options: BlueskyPostOptions(
                    url: "https://grain.social/profile/\(draft.repo)/gallery/\(draft.galleryRkey)",
                    title: draft.title.isEmpty ? nil : draft.title,
                    location: draft.location.map { ($0.name, $0.address) },
                    description: draft.description.isEmpty ? nil : draft.description,
                    images: images
                ),
                client: client,
                repo: draft.repo,
                auth: authContext,
                rkey: draft.blueskyRkey,
                createdAt: draft.createdAt
            )
        } catch {
            logger.error("Bluesky cross-post failed: \(error.localizedDescription)")
        }
    }

    // MARK: - Copy

    static func message(for error: Error) -> String {
        if let urlError = error as? URLError {
            switch urlError.code {
            case .notConnectedToInternet:
                return "You're offline. Your gallery is saved — try again once you have a connection."
            case .timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed:
                return "The connection dropped. Nothing was lost — trying again picks up where it stopped."
            default:
                break
            }
        }
        if let xrpcError = error as? XRPCError {
            switch xrpcError {
            case .unauthorized:
                return "Your session expired. Sign in again to finish posting."
            case let .httpError(statusCode, body):
                if statusCode == 429 {
                    return "Your server is rate limiting uploads. Wait a moment and try again."
                }
                if (500 ... 599).contains(statusCode) {
                    return "The server had a problem. Nothing was lost — try again in a moment."
                }
                let detail = body.flatMap { String(data: $0, encoding: .utf8) }
                return detail.map { "Couldn't post (HTTP \(statusCode)): \($0)" } ?? "Couldn't post (HTTP \(statusCode))."
            default:
                break
            }
        }
        if let draftError = error as? GalleryDraftError {
            return draftError.localizedDescription
        }
        return "Couldn't post: \(error.localizedDescription)"
    }
}