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

grain.social/grain-ios

1 collaborator
0
1
main
324 lines · 11.7 KB Raw History
import NukeUI
import SwiftUI

struct GalleryDetailView: View {
    @Environment(AuthManager.self) private var auth
    @State private var viewModel: GalleryDetailViewModel
    @Environment(Router.self) private var router
    @State private var showDeleteConfirmation = false
    @State private var deleteErrorMessage: String?
    @State private var showReportSheet = false
    @State private var showCommentSheet = false
    @State private var zoomState = ImageZoomState()
    @State private var cardStoryAuthor: GrainStoryAuthor?
    @Environment(\.dismiss) private var dismiss

    let client: XRPCClient
    let galleryUri: String
    @Binding var deletedGalleryUri: String?

    init(client: XRPCClient, galleryUri: String, deletedGalleryUri: Binding<String?> = .constant(nil)) {
        self.client = client
        _viewModel = State(initialValue: GalleryDetailViewModel(client: client))
        self.galleryUri = galleryUri
        _deletedGalleryUri = deletedGalleryUri
    }

    var body: some View {
        ScrollView {
            if viewModel.gallery != nil {
                VStack(spacing: 0) {
                    GalleryCardView(gallery: Binding(
                        get: { viewModel.gallery! },
                        set: { viewModel.gallery = $0 }
                    ), client: client, onCommentTap: {
                        showCommentSheet = true
                    }, onStoryTap: { author in
                        cardStoryAuthor = author
                    }, onReport: viewModel.gallery?.creator.did != auth.userDID ? { showReportSheet = true } : nil, onDelete: viewModel.gallery?.creator.did == auth.userDID ? { showDeleteConfirmation = true } : nil)

                    // View comments button
                    Button {
                        showCommentSheet = true
                    } label: {
                        HStack {
                            let count = viewModel.gallery?.commentCount ?? 0
                            if count > 0 {
                                Text("View all \(count) comments")
                            } else {
                                Text("Add a comment...")
                            }
                            Spacer()
                        }
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .padding(.horizontal, 12)
                        .padding(.vertical, 12)
                    }
                    .buttonStyle(.plain)
                }
            } else {
                ProgressView()
                    .padding(.top, 100)
            }
        }
        .environment(zoomState)
        .modifier(ImageZoomOverlay(zoomState: zoomState))
        .navigationBarTitleDisplayMode(.inline)
        .toolbarTitleDisplayMode(.inline)
        .alert("Delete gallery?", isPresented: $showDeleteConfirmation) {
            Button("Delete", role: .destructive) {
                Task { await deleteGallery() }
            }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("This will permanently delete this gallery and all its photos.")
        }
        .alert("Couldn't delete gallery", isPresented: Binding(
            get: { deleteErrorMessage != nil },
            set: {
                if !$0 {
                    deleteErrorMessage = nil
                }
            }
        )) {
            Button("OK", role: .cancel) { deleteErrorMessage = nil }
        } message: {
            Text(deleteErrorMessage ?? "")
        }
        .sheet(isPresented: $showReportSheet) {
            if let gallery = viewModel.gallery {
                ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid)
            }
        }
        .fullScreenCover(item: $cardStoryAuthor) { author in
            StoryViewer(
                authors: [author],
                client: client,
                onProfileTap: { did in
                    cardStoryAuthor = nil
                    router.push(.profile(did: did))
                },
                onDismiss: { cardStoryAuthor = nil }
            )
            .environment(auth)
        }
        .sheet(isPresented: $showCommentSheet) {
            CommentSheetView(
                client: client,
                galleryUri: galleryUri,
                onDismiss: { showCommentSheet = false },
                onProfileTap: { did in
                    showCommentSheet = false
                    router.push(.profile(did: did))
                },
                onHashtagTap: { tag in
                    showCommentSheet = false
                    router.push(.hashtag(tag))
                },
                onStoryTap: { author in
                    showCommentSheet = false
                    cardStoryAuthor = author
                },
                onCommentCountChanged: { count in
                    viewModel.gallery?.commentCount = count
                }
            )
        }
        .task {
            guard !isPreview else {
                #if DEBUG
                    viewModel.gallery = PreviewData.gallery1
                    viewModel.comments = PreviewData.comments
                #endif
                return
            }
            await viewModel.load(uri: galleryUri, auth: auth.authContext())
        }
    }

    private func deleteGallery() async {
        switch await GalleryService.delete(galleryUri: galleryUri, client: client, auth: auth) {
        case .success:
            deletedGalleryUri = galleryUri
            dismiss()
        case let .failure(error):
            deleteErrorMessage = error.localizedDescription
        }
    }
}

/// CommentRow stays here since it's shared
struct CommentRow: View {
    @Environment(StoryStatusCache.self) private var storyStatusCache
    @Environment(ViewedStoryStorage.self) private var viewedStories
    @Environment(AuthManager.self) private var auth
    let comment: GrainComment
    let client: XRPCClient
    let userDID: String?
    var isOwn: Bool = false
    var isReply: Bool = false
    var onProfileTap: ((String) -> Void)?
    var onHashtagTap: ((String) -> Void)?
    var onStoryTap: ((GrainStoryAuthor) -> Void)?
    var onReply: (() -> Void)?
    var onDelete: (() -> Void)?
    @State private var expanded = false
    @State private var favUri: String?
    @State private var favCountOffset: Int = 0
    @State private var isMutating = false

    private var isFaved: Bool {
        favUri != nil
    }

    private var displayFavCount: Int {
        (comment.favCount ?? 0) + favCountOffset
    }

    var body: some View {
        if comment.muted == true, !expanded {
            Button {
                expanded = true
            } label: {
                HStack(spacing: 6) {
                    Image(systemName: "speaker.slash")
                        .font(.caption)
                    Text("Muted comment")
                        .font(.caption)
                }
                .foregroundStyle(.secondary)
                .padding(.leading, isReply ? 50 : 12)
                .padding(.trailing, 12)
                .padding(.vertical, 8)
            }
            .buttonStyle(.plain)
        } else {
            HStack(alignment: .top, spacing: 8) {
                let avatarSize: CGFloat = isReply ? 24 : 28
                StoryRingView(
                    hasStory: storyStatusCache.hasStory(for: comment.author.did),
                    viewed: comment.author.did != userDID && viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache),
                    size: avatarSize
                ) {
                    AvatarView(url: comment.author.avatar, size: avatarSize)
                }
                .onTapGesture {
                    if let author = storyStatusCache.author(for: comment.author.did) {
                        onStoryTap?(author)
                    } else {
                        onProfileTap?(comment.author.did)
                    }
                }
                .onLongPressGesture {
                    onProfileTap?(comment.author.did)
                }

                VStack(alignment: .leading, spacing: 2) {
                    HStack(spacing: 4) {
                        Text(comment.author.displayName ?? comment.author.handle)
                            .font(.subheadline.weight(.semibold))
                        Text(DateFormatting.relativeTime(comment.createdAt))
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    RichTextView(
                        text: comment.text,
                        facets: comment.facets,
                        onMentionTap: onProfileTap,
                        onHashtagTap: onHashtagTap
                    )

                    HStack(spacing: 16) {
                        if onReply != nil {
                            Button {
                                onReply?()
                            } label: {
                                Text("Reply")
                                    .font(.caption.weight(.bold))
                                    .foregroundStyle(.gray)
                            }
                        }
                        if isOwn {
                            Button {
                                onDelete?()
                            } label: {
                                Text("Delete")
                                    .font(.caption.weight(.bold))
                                    .foregroundStyle(.gray)
                            }
                        }
                    }
                    .padding(.top, 2)
                }

                Spacer(minLength: 0)

                VStack(spacing: 2) {
                    Button {
                        Task { await toggleFav() }
                    } label: {
                        Image(systemName: isFaved ? "heart.fill" : "heart")
                            .font(.system(size: 16))
                            .foregroundStyle(isFaved ? Color.heart : .secondary)
                    }
                    .buttonStyle(.plain)
                    .disabled(isMutating)

                    if displayFavCount > 0 {
                        Text(displayFavCount.compactCount)
                            .font(.system(size: 11))
                            .foregroundStyle(.secondary)
                    }
                }
                .frame(maxHeight: .infinity)
            }
            .padding(.leading, isReply ? 50 : 12)
            .padding(.trailing, 12)
            .padding(.vertical, 8)
            .task {
                favUri = comment.viewer?.fav
            }
        }
    }

    private func toggleFav() async {
        guard !isMutating else { return }
        guard let authCtx = await auth.authContext() else { return }
        isMutating = true
        defer { isMutating = false }

        if let uri = favUri {
            // Optimistic unfavorite
            favUri = nil
            favCountOffset -= 1
            do {
                try await FavoriteService.delete(favoriteUri: uri, client: client, auth: authCtx)
            } catch {
                // Revert
                favUri = uri
                favCountOffset += 1
            }
        } else {
            // Optimistic favorite
            favUri = "pending"
            favCountOffset += 1
            do {
                let result = try await FavoriteService.create(subject: comment.uri, client: client, auth: authCtx)
                favUri = result.uri
            } catch {
                // Revert
                favUri = nil
                favCountOffset -= 1
            }
        }
    }
}

#Preview {
    GalleryDetailView(
        client: .preview,
        galleryUri: "at://did:plc:preview/social.grain.gallery/r1"
    )
    .previewEnvironments()
}