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

grain.social/grain-ios

1 collaborator
0
1
main
239 lines · 8.9 KB Raw History
import SwiftUI

enum CommentDismissStyle {
    case xmark
    case done
}

struct CommentSheetContent: View {
    let comments: [GrainComment]
    let isLoading: Bool
    let isPostingComment: Bool
    var client: XRPCClient?

    var onPost: (String, GrainComment?) async -> Void
    var onDelete: (GrainComment) async -> Void

    var onDismiss: () -> Void = {}
    var onProfileTap: ((String) -> Void)?
    var onHashtagTap: ((String) -> Void)?
    var onStoryTap: ((GrainStoryAuthor) -> Void)?

    var dismissStyle: CommentDismissStyle = .xmark
    var focusOnAppear: Bool = false

    @Environment(AuthManager.self) private var auth
    @Environment(\.dismiss) private var dismiss

    @State private var commentText = ""
    @State private var replyingTo: GrainComment?
    @State private var mentionState = MentionAutocompleteState()
    @FocusState private var commentFocused: Bool

    private var threadedComments: [(root: GrainComment, replies: [GrainComment])] {
        let roots = comments.filter { $0.replyTo == nil }
        let replyMap = Dictionary(grouping: comments.filter { $0.replyTo != nil }, by: { $0.replyTo! })
        return roots.map { root in
            (root: root, replies: replyMap[root.uri] ?? [])
        }
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                commentList
            }
            .safeAreaInset(edge: .bottom) {
                VStack(spacing: 0) {
                    MentionSuggestionOverlay(state: mentionState) { suggestion in
                        mentionState.complete(handle: suggestion.handle, in: &commentText)
                    }
                    glassInputPill
                }
            }
            .navigationTitle("Comments")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                switch dismissStyle {
                case .xmark:
                    ToolbarItem(placement: .topBarLeading) {
                        Button {
                            onDismiss()
                        } label: {
                            Image(systemName: "xmark")
                                .font(.subheadline.weight(.semibold))
                                .foregroundStyle(.primary)
                        }
                        .accessibilityLabel("Close comments")
                    }
                case .done:
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Done") {
                            onDismiss()
                        }
                    }
                }
            }
        }
        .task {
            if focusOnAppear {
                commentFocused = true
            }
        }
    }

    // MARK: - Comment list

    @ViewBuilder
    private var commentList: some View {
        if isLoading, comments.isEmpty {
            Spacer()
            ProgressView()
            Spacer()
        } else if comments.isEmpty {
            Spacer()
            Text("No comments yet")
                .foregroundStyle(.secondary)
                .font(.subheadline)
            Spacer()
        } else {
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(threadedComments, id: \.root.id) { thread in
                        CommentRow(
                            comment: thread.root,
                            client: client ?? XRPCClient(baseURL: AuthManager.serverURL),
                            userDID: auth.userDID,
                            isOwn: thread.root.author.did == auth.userDID,
                            isReply: false,
                            onProfileTap: onProfileTap,
                            onHashtagTap: onHashtagTap,
                            onStoryTap: onStoryTap,
                            onReply: { startReply(to: thread.root) },
                            onDelete: { Task { await onDelete(thread.root) } }
                        )

                        ForEach(thread.replies) { reply in
                            CommentRow(
                                comment: reply,
                                client: client ?? XRPCClient(baseURL: AuthManager.serverURL),
                                userDID: auth.userDID,
                                isOwn: reply.author.did == auth.userDID,
                                isReply: true,
                                onProfileTap: onProfileTap,
                                onHashtagTap: onHashtagTap,
                                onStoryTap: onStoryTap,
                                onReply: { startReplyToReply(reply, root: thread.root) },
                                onDelete: { Task { await onDelete(reply) } }
                            )
                        }
                    }
                }
            }
        }
    }

    // MARK: - Glass input pill

    private var glassInputPill: some View {
        VStack(spacing: 0) {
            if let replyTarget = replyingTo {
                HStack {
                    Image(systemName: "arrowshape.turn.up.left.fill")
                        .font(.caption2)
                        .foregroundStyle(.primary)
                        .accessibilityHidden(true)
                    Text("Replying to")
                        .foregroundStyle(.secondary)
                    Text("@\(replyTarget.author.handle)")
                        .foregroundStyle(.primary)
                    Spacer()
                    Button {
                        replyingTo = nil
                    } label: {
                        Image(systemName: "xmark")
                            .font(.caption.weight(.semibold))
                            .foregroundStyle(Color.accentColor)
                            .frame(width: 32, height: 32)
                            .contentShape(Rectangle())
                    }
                    .buttonStyle(.plain)
                    .accessibilityLabel("Cancel reply")
                }
                .font(.subheadline.weight(.medium))
                .padding(.leading, 16)
                .padding(.trailing, 8)
                .padding(.top, 6)
            }

            GlassEffectContainer(spacing: 8) {
                let isEmpty = commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
                HStack(alignment: .bottom, spacing: 0) {
                    TextField(replyingTo != nil ? "Reply..." : "Add a comment...", text: $commentText, axis: .vertical)
                        .textFieldStyle(.plain)
                        .font(.body)
                        .focused($commentFocused)
                        .lineLimit(1 ... 5)
                        .onChange(of: commentText) { mentionState.update(text: commentText) }
                        .padding(.leading, 18)
                        .padding(.trailing, 8)
                        .padding(.vertical, 12)

                    if !isEmpty {
                        Button {
                            Task { await postComment() }
                        } label: {
                            Image(systemName: "arrow.up")
                                .font(.system(size: 14, weight: .bold))
                                .foregroundStyle(.white)
                                .frame(width: 32, height: 32)
                                .background(Color.accentColor, in: .circle)
                        }
                        .accessibilityLabel("Send comment")
                        .buttonStyle(.plain)
                        .disabled(isPostingComment)
                        .padding(.trailing, 8)
                        .padding(.bottom, 7)
                        .transition(
                            .asymmetric(
                                insertion: .scale.combined(with: .opacity),
                                removal: .scale.combined(with: .opacity)
                            )
                        )
                    }
                }
                .glassEffect(.regular.tint(.primary.opacity(0.1)), in: .capsule)
                .clipped()
                .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isEmpty)
                .padding(.horizontal, 12)
                .padding(.vertical, 10)
            }
        }
        .padding(.horizontal, 12)
        .padding(.bottom, 8)
    }

    // MARK: - Actions

    private func startReply(to comment: GrainComment) {
        replyingTo = comment
        commentText = ""
        commentFocused = true
    }

    private func startReplyToReply(_ reply: GrainComment, root: GrainComment) {
        replyingTo = root
        commentText = "@\(reply.author.handle) "
        commentFocused = true
    }

    private func postComment() async {
        let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else { return }
        let reply = replyingTo
        commentText = ""
        replyingTo = nil
        commentFocused = false
        await onPost(text, reply)
    }
}