krz/hutch

an ios client for sourcehut

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

v3.4.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    @State private var showInfoSheet = false
 14    @State private var showFileShareSheet = false
 15    @State private var showShareUnavailableAlert = false
 16    @State private var didCopyContents = false
 17    @State private var copyResetTask: Task<Void, Never>?
 18    @AppStorage(AppStorageKeys.wrapPasteFileLines) private var wrapLines = false
 19
 20    var body: some View {
 21        Group {
 22            if let viewModel {
 23                content(viewModel)
 24            } else {
 25                SRHTLoadingStateView(message: "Loading paste…")
 26            }
 27        }
 28        .navigationTitle(displayTitle)
 29        .navigationBarTitleDisplayMode(.inline)
 30        .toolbar {
 31            ToolbarItemGroup(placement: .topBarTrailing) {
 32                if viewModel != nil {
 33                    Menu {
 34                        if let url = currentPaste.flatMap({ SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }) {
 35                            ShareLink(item: url) {
 36                                Label("Share Link", systemImage: "link")
 37                            }
 38                        }
 39
 40                        Button {
 41                            showVisibilitySheet = true
 42                        } label: {
 43                            Label("Change Visibility", systemImage: "eye")
 44                        }
 45
 46                        Button(role: .destructive) {
 47                            showDeleteConfirmation = true
 48                        } label: {
 49                            Label("Delete Paste", systemImage: "trash")
 50                        }
 51                    } label: {
 52                        Image(systemName: "ellipsis.circle")
 53                    }
 54                }
 55            }
 56        }
 57        .sheet(isPresented: $showVisibilitySheet) {
 58            if let viewModel, let currentPaste {
 59                PasteVisibilitySheet(
 60                    currentVisibility: currentPaste.visibility,
 61                    isUpdating: viewModel.isUpdatingVisibility
 62                ) { visibility in
 63                    if let updated = await viewModel.updateVisibility(visibility) {
 64                        onUpdated?(updated)
 65                        showVisibilitySheet = false
 66                    }
 67                }
 68            }
 69        }
 70        .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
 71            Button("Cancel", role: .cancel) {
 72                // Alert dismissal is implicit; no additional action required.
 73            }
 74            Button("Delete", role: .destructive) {
 75                Task {
 76                    if await viewModel?.deletePaste() == true {
 77                        onDeleted?(paste.id)
 78                        dismiss()
 79                    }
 80                }
 81            }
 82        } message: {
 83            Text("This paste will be permanently removed.")
 84        }
 85        .task {
 86            if viewModel == nil {
 87                let vm = PasteDetailViewModel(
 88                    pasteID: paste.id,
 89                    initialPaste: paste,
 90                    service: PasteService(client: appState.client)
 91                )
 92                viewModel = vm
 93                await vm.loadPaste()
 94            }
 95        }
 96    }
 97
 98    private var currentPaste: Paste? {
 99        viewModel?.paste ?? paste
100    }
101
102    private var displayTitle: String {
103        if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
104            return filename
105        }
106        return "Paste \(paste.id)"
107    }
108
109    @ViewBuilder
110    private func content(_ viewModel: PasteDetailViewModel) -> some View {
111        @Bindable var vm = viewModel
112
113        if viewModel.isLoading, viewModel.paste == nil {
114            SRHTLoadingStateView(message: "Loading paste…")
115        } else if let error = viewModel.error, viewModel.paste == nil {
116            SRHTErrorStateView(
117                title: "Couldn't Load Paste",
118                message: error,
119                retryAction: { await viewModel.loadPaste() }
120            )
121        } else if let paste = viewModel.paste {
122            VStack(spacing: 0) {
123                fileHeaderBar(paste: paste, viewModel: viewModel)
124                Divider()
125                fileContentArea(viewModel: viewModel)
126            }
127            .safeAreaInset(edge: .bottom, spacing: 0) {
128                actionToolbar(viewModel: viewModel)
129            }
130            .srhtErrorBanner(error: $vm.error)
131            .sheet(isPresented: $showInfoSheet) {
132                PasteInfoSheet(paste: paste, selectedFile: viewModel.selectedFile)
133            }
134            .sheet(isPresented: $showFileShareSheet) {
135                if let contents = viewModel.selectedFileContents {
136                    FileContentShareSheet(activityItems: [contents])
137                }
138            }
139            .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
140                Button("OK", role: .cancel) {
141                    // Alert dismissal is implicit; no additional action required.
142                }
143            } message: {
144                Text(SRHTShareTarget.file.fallbackMessage)
145            }
146        }
147    }
148
149    // MARK: - File Header Bar
150
151    private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View {
152        VStack(alignment: .leading, spacing: 6) {
153            HStack(alignment: .firstTextBaseline, spacing: 8) {
154                let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } }
155                Text(filename ?? "Untitled")
156                    .font(.subheadline.monospaced())
157                    .foregroundStyle(.primary)
158                    .lineLimit(1)
159
160                Spacer(minLength: 4)
161
162                VisibilityBadge(visibility: paste.visibility)
163            }
164
165            HStack(spacing: 4) {
166                Text(paste.user.canonicalName)
167                    .font(.caption)
168                    .foregroundStyle(.secondary)
169
170                Text("·")
171                    .font(.caption)
172                    .foregroundStyle(.tertiary)
173
174                Text(paste.created.relativeDescription)
175                    .font(.caption)
176                    .foregroundStyle(.secondary)
177
178                if let hash = viewModel.selectedFile?.hash {
179                    Text("·")
180                        .font(.caption)
181                        .foregroundStyle(.tertiary)
182
183                    Text(hash.prefix(8))
184                        .font(.caption.monospaced())
185                        .foregroundStyle(.tertiary)
186                }
187            }
188
189            if paste.files.count > 1 {
190                Picker(
191                    "File",
192                    selection: Binding(
193                        get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
194                        set: { viewModel.selectFile(hash: $0) }
195                    )
196                ) {
197                    ForEach(paste.files) { file in
198                        Text(file.filename ?? String(file.hash.prefix(8)))
199                            .tag(file.hash)
200                    }
201                }
202                .pickerStyle(.segmented)
203            }
204        }
205        .padding(.horizontal)
206        .padding(.vertical, 10)
207        .background(.bar)
208    }
209
210    // MARK: - File Content Area
211
212    @ViewBuilder
213    private func fileContentArea(viewModel: PasteDetailViewModel) -> some View {
214        if let file = viewModel.selectedFile {
215            if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
216                SRHTLoadingStateView(message: "Loading contents…")
217            } else if let contents = viewModel.selectedFileContents {
218                CodeFileTextView(
219                    text: contents,
220                    fileName: file.filename ?? "",
221                    wrapLines: wrapLines
222                )
223            } else {
224                ContentUnavailableView(
225                    "Contents Unavailable",
226                    systemImage: "doc.questionmark",
227                    description: Text("This file's contents could not be loaded.")
228                )
229            }
230        } else {
231            ContentUnavailableView(
232                "No File Selected",
233                systemImage: "doc",
234                description: Text("Select a file to view its contents.")
235            )
236        }
237    }
238
239    // MARK: - Action Toolbar
240
241    private func actionToolbar(viewModel: PasteDetailViewModel) -> some View {
242        HStack(spacing: 0) {
243            toolbarButton(
244                title: didCopyContents ? "Copied" : "Copy All",
245                systemImage: didCopyContents ? "checkmark" : "doc.on.doc"
246            ) {
247                if let contents = viewModel.selectedFileContents {
248                    UIPasteboard.general.string = contents
249                    didCopyContents = true
250                    copyResetTask?.cancel()
251                    copyResetTask = Task {
252                        try? await Task.sleep(for: .seconds(2))
253                        guard !Task.isCancelled else { return }
254                        await MainActor.run { didCopyContents = false }
255                    }
256                }
257            }
258            .disabled(viewModel.selectedFileContents == nil)
259
260            toolbarButton(
261                title: "Share",
262                systemImage: "square.and.arrow.up"
263            ) {
264                if let contents = viewModel.selectedFileContents, !contents.isEmpty {
265                    showFileShareSheet = true
266                } else {
267                    showShareUnavailableAlert = true
268                }
269            }
270            .disabled(viewModel.selectedFileContents == nil)
271
272            toolbarButton(
273                title: wrapLines ? "Wrap On" : "Wrap Off",
274                systemImage: "text.word.spacing"
275            ) {
276                wrapLines.toggle()
277            }
278
279            toolbarButton(
280                title: "Details",
281                systemImage: "info.circle"
282            ) {
283                showInfoSheet = true
284            }
285        }
286        .padding(.horizontal, 8)
287        .padding(.top, 10)
288        .padding(.bottom, 8)
289        .background(.bar)
290        .overlay(alignment: .top) {
291            Divider()
292        }
293    }
294
295    private func toolbarButton(
296        title: String,
297        systemImage: String,
298        action: @escaping () -> Void
299    ) -> some View {
300        Button(action: action) {
301            VStack(spacing: 4) {
302                Image(systemName: systemImage)
303                    .font(.system(size: 17, weight: .semibold))
304                Text(title)
305                    .font(.caption2)
306                    .lineLimit(1)
307            }
308            .frame(maxWidth: .infinity)
309            .contentShape(Rectangle())
310        }
311        .buttonStyle(.plain)
312        .foregroundStyle(.primary)
313    }
314}
315
316// MARK: - Paste Info Sheet
317
318private struct PasteInfoSheet: View {
319    let paste: Paste
320    let selectedFile: PasteFile?
321
322    @Environment(\.dismiss) private var dismiss
323
324    var body: some View {
325        NavigationStack {
326            List {
327                Section("Paste") {
328                    LabeledContent("ID", value: paste.id)
329                        .themedRow()
330                    LabeledContent("Owner", value: paste.user.canonicalName)
331                        .themedRow()
332                    LabeledContent("Created", value: paste.created.relativeDescription)
333                        .themedRow()
334                    LabeledContent("Visibility") {
335                        VisibilityBadge(visibility: paste.visibility)
336                    }
337                    .themedRow()
338                    if paste.files.count > 1 {
339                        LabeledContent("Files", value: "\(paste.files.count)")
340                            .themedRow()
341                    }
342                }
343
344                if let file = selectedFile {
345                    Section("File") {
346                        if let filename = file.filename, !filename.isEmpty {
347                            LabeledContent("Filename", value: filename)
348                                .themedRow()
349                        }
350                        LabeledContent("Hash") {
351                            Text(file.hash)
352                                .font(.caption.monospaced())
353                                .foregroundStyle(.secondary)
354                                .textSelection(.enabled)
355                        }
356                        .themedRow()
357                    }
358                }
359            }
360            .listStyle(.insetGrouped)
361            .themedList()
362            .navigationTitle("Details")
363            .navigationBarTitleDisplayMode(.inline)
364            .toolbar {
365                ToolbarItem(placement: .confirmationAction) {
366                    Button("Done") { dismiss() }
367                }
368            }
369        }
370    }
371}
372
373// MARK: - Visibility Sheet
374
375private struct PasteVisibilitySheet: View {
376    let currentVisibility: Visibility
377    let isUpdating: Bool
378    let onSave: (Visibility) async -> Void
379
380    @Environment(\.dismiss) private var dismiss
381    @State private var visibility: Visibility
382
383    init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
384        self.currentVisibility = currentVisibility
385        self.isUpdating = isUpdating
386        self.onSave = onSave
387        _visibility = State(initialValue: currentVisibility)
388    }
389
390    var body: some View {
391        NavigationStack {
392            List {
393                ForEach(visibilityOptions, id: \.self) { option in
394                    Button {
395                        visibility = option
396                    } label: {
397                        HStack {
398                            VStack(alignment: .leading, spacing: 2) {
399                                Text(title(for: option))
400                                    .foregroundStyle(.primary)
401                                Text(description(for: option))
402                                    .font(.caption)
403                                    .foregroundStyle(.secondary)
404                            }
405
406                            Spacer()
407
408                            if visibility == option {
409                                Image(systemName: "checkmark")
410                                    .foregroundStyle(.tint)
411                            }
412                        }
413                        .contentShape(Rectangle())
414                    }
415                    .buttonStyle(.plain)
416                }
417                .themedRow()
418            }
419            .listStyle(.insetGrouped)
420            .navigationTitle("Visibility")
421            .navigationBarTitleDisplayMode(.inline)
422            .toolbar {
423                ToolbarItem(placement: .cancellationAction) {
424                    Button("Cancel") { dismiss() }
425                }
426                ToolbarItem(placement: .confirmationAction) {
427                    Button("Save") {
428                        Task {
429                            await onSave(visibility)
430                        }
431                    }
432                    .disabled(isUpdating || visibility == currentVisibility)
433                }
434            }
435            .overlay {
436                if isUpdating {
437                    ProgressView()
438                }
439            }
440            .themedList()
441        }
442    }
443
444    private var visibilityOptions: [Visibility] {
445        [.publicVisibility, .unlisted, .privateVisibility]
446    }
447
448    private func title(for visibility: Visibility) -> String {
449        switch visibility {
450        case .publicVisibility:
451            "Public"
452        case .unlisted:
453            "Unlisted"
454        case .privateVisibility:
455            "Private"
456        }
457    }
458
459    private func description(for visibility: Visibility) -> String {
460        switch visibility {
461        case .publicVisibility:
462            "Visible to everyone and listed on your profile."
463        case .unlisted:
464            "Visible to anyone with the URL, but not listed on your profile."
465        case .privateVisibility:
466            "Visible only to explicitly allowed viewers."
467        }
468    }
469}