krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.3.0: Hutch/Views/Pastes/PasteDetailView.swift · raw

  1import SwiftUI
  2
  3struct PasteDetailView: View {
  4    let paste: Paste
  5    var onUpdated: ((Paste) -> Void)? = nil
  6    var onDeleted: ((String) -> Void)? = nil
  7
  8    @Environment(AppState.self) private var appState
  9    @Environment(\.dismiss) private var dismiss
 10    @State private var viewModel: PasteDetailViewModel?
 11    @State private var showVisibilitySheet = false
 12    @State private var showDeleteConfirmation = false
 13
 14    var body: some View {
 15        Group {
 16            if let viewModel {
 17                content(viewModel)
 18            } else {
 19                SRHTLoadingStateView(message: "Loading paste…")
 20            }
 21        }
 22        .navigationTitle(displayTitle)
 23        .navigationBarTitleDisplayMode(.inline)
 24        .toolbar {
 25            ToolbarItemGroup(placement: .topBarTrailing) {
 26                SRHTShareButton(
 27                    url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
 28                    target: .paste
 29                ) {
 30                    Image(systemName: "square.and.arrow.up")
 31                }
 32
 33                if viewModel != nil {
 34                    Menu {
 35                        Button {
 36                            showVisibilitySheet = true
 37                        } label: {
 38                            Label("Change Visibility", systemImage: "eye")
 39                        }
 40
 41                        Button(role: .destructive) {
 42                            showDeleteConfirmation = true
 43                        } label: {
 44                            Label("Delete Paste", systemImage: "trash")
 45                        }
 46                    } label: {
 47                        Image(systemName: "ellipsis.circle")
 48                    }
 49                }
 50            }
 51        }
 52        .sheet(isPresented: $showVisibilitySheet) {
 53            if let viewModel, let currentPaste {
 54                PasteVisibilitySheet(
 55                    currentVisibility: currentPaste.visibility,
 56                    isUpdating: viewModel.isUpdatingVisibility
 57                ) { visibility in
 58                    if let updated = await viewModel.updateVisibility(visibility) {
 59                        onUpdated?(updated)
 60                        showVisibilitySheet = false
 61                    }
 62                }
 63            }
 64        }
 65        .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
 66            Button("Cancel", role: .cancel) {}
 67            Button("Delete", role: .destructive) {
 68                Task {
 69                    if await viewModel?.deletePaste() == true {
 70                        onDeleted?(paste.id)
 71                        dismiss()
 72                    }
 73                }
 74            }
 75        } message: {
 76            Text("This paste will be permanently removed.")
 77        }
 78        .task {
 79            if viewModel == nil {
 80                let vm = PasteDetailViewModel(
 81                    pasteID: paste.id,
 82                    initialPaste: paste,
 83                    service: PasteService(client: appState.client)
 84                )
 85                viewModel = vm
 86                await vm.loadPaste()
 87            }
 88        }
 89    }
 90
 91    private var currentPaste: Paste? {
 92        viewModel?.paste ?? paste
 93    }
 94
 95    private var displayTitle: String {
 96        if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
 97            return filename
 98        }
 99        return "Paste \(paste.id)"
100    }
101
102    @ViewBuilder
103    private func content(_ viewModel: PasteDetailViewModel) -> some View {
104        @Bindable var vm = viewModel
105
106        if viewModel.isLoading, viewModel.paste == nil {
107            SRHTLoadingStateView(message: "Loading paste…")
108        } else if let error = viewModel.error, viewModel.paste == nil {
109            SRHTErrorStateView(
110                title: "Couldn't Load Paste",
111                message: error,
112                retryAction: { await viewModel.loadPaste() }
113            )
114        } else if let paste = viewModel.paste {
115            List {
116                Section("Details") {
117                    LabeledContent("ID", value: paste.id)
118                    LabeledContent("Owner", value: paste.user.canonicalName)
119                    LabeledContent("Created", value: paste.created.relativeDescription)
120                    LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
121                    LabeledContent("Files", value: "\(paste.files.count)")
122                }
123
124                if paste.files.count > 1 {
125                    Section("Files") {
126                        Picker("Selected File", selection: Binding(
127                            get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
128                            set: { viewModel.selectFile(hash: $0) }
129                        )) {
130                            ForEach(paste.files) { file in
131                                Text(file.filename ?? String(file.hash.prefix(8)))
132                                    .tag(file.hash)
133                            }
134                        }
135                    }
136                }
137
138                if let file = viewModel.selectedFile {
139                    Section("Current File") {
140                        if let filename = file.filename, !filename.isEmpty {
141                            LabeledContent("Filename", value: filename)
142                        }
143                        LabeledContent("Hash", value: file.hash)
144                    }
145
146                    Section {
147                        if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
148                            SRHTLoadingStateView(message: "Loading paste contents…")
149                                .frame(minHeight: 180)
150                        } else if let contents = viewModel.selectedFileContents {
151                            PasteCodeBlock(text: contents)
152                        } else {
153                            Text("This file’s contents are unavailable.")
154                                .foregroundStyle(.secondary)
155                        }
156                    } header: {
157                        Text("Contents")
158                    }
159                }
160            }
161            .listStyle(.insetGrouped)
162            .srhtErrorBanner(error: $vm.error)
163            .refreshable {
164                await viewModel.loadPaste()
165            }
166        }
167    }
168
169    private func visibilityLabel(_ visibility: Visibility) -> String {
170        switch visibility {
171        case .public:
172            return "Public"
173        case .unlisted:
174            return "Unlisted"
175        case .private:
176            return "Private"
177        }
178    }
179}
180
181private struct PasteCodeBlock: View {
182    let text: String
183
184    var body: some View {
185        ScrollView([.horizontal, .vertical], showsIndicators: true) {
186            Text(text.isEmpty ? " " : text)
187                .font(.system(.body, design: .monospaced))
188                .textSelection(.enabled)
189                .frame(maxWidth: .infinity, alignment: .leading)
190                .padding(.vertical, 4)
191        }
192        .frame(minHeight: 220)
193    }
194}
195
196private struct PasteVisibilitySheet: View {
197    let currentVisibility: Visibility
198    let isUpdating: Bool
199    let onSave: (Visibility) async -> Void
200
201    @Environment(\.dismiss) private var dismiss
202    @State private var visibility: Visibility
203
204    init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
205        self.currentVisibility = currentVisibility
206        self.isUpdating = isUpdating
207        self.onSave = onSave
208        _visibility = State(initialValue: currentVisibility)
209    }
210
211    var body: some View {
212        NavigationStack {
213            List {
214                ForEach(visibilityOptions, id: \.self) { option in
215                    Button {
216                        visibility = option
217                    } label: {
218                        HStack {
219                            VStack(alignment: .leading, spacing: 2) {
220                                Text(title(for: option))
221                                    .foregroundStyle(.primary)
222                                Text(description(for: option))
223                                    .font(.caption)
224                                    .foregroundStyle(.secondary)
225                            }
226
227                            Spacer()
228
229                            if visibility == option {
230                                Image(systemName: "checkmark")
231                                    .foregroundStyle(.tint)
232                            }
233                        }
234                        .contentShape(Rectangle())
235                    }
236                    .buttonStyle(.plain)
237                }
238            }
239            .listStyle(.insetGrouped)
240            .navigationTitle("Visibility")
241            .navigationBarTitleDisplayMode(.inline)
242            .toolbar {
243                ToolbarItem(placement: .cancellationAction) {
244                    Button("Cancel") { dismiss() }
245                }
246                ToolbarItem(placement: .confirmationAction) {
247                    Button("Save") {
248                        Task {
249                            await onSave(visibility)
250                        }
251                    }
252                    .disabled(isUpdating || visibility == currentVisibility)
253                }
254            }
255            .overlay {
256                if isUpdating {
257                    ProgressView()
258                }
259            }
260        }
261    }
262
263    private var visibilityOptions: [Visibility] {
264        [.public, .unlisted, .private]
265    }
266
267    private func title(for visibility: Visibility) -> String {
268        switch visibility {
269        case .public:
270            "Public"
271        case .unlisted:
272            "Unlisted"
273        case .private:
274            "Private"
275        }
276    }
277
278    private func description(for visibility: Visibility) -> String {
279        switch visibility {
280        case .public:
281            "Visible to everyone and listed on your profile."
282        case .unlisted:
283            "Visible to anyone with the URL, but not listed on your profile."
284        case .private:
285            "Visible only to explicitly allowed viewers."
286        }
287    }
288}