krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.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}