krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: Hutch/Views/Pastes/PasteListView.swift · raw

  1import SwiftUI
  2
  3struct PasteListView: View {
  4    @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
  5    @Environment(AppState.self) private var appState
  6    @State private var viewModel: PasteListViewModel?
  7    @State private var showCreatePasteSheet = false
  8    @State private var createdPaste: Paste?
  9    @State private var pasteToDelete: Paste?
 10
 11    var body: some View {
 12        Group {
 13            if let viewModel {
 14                content(viewModel)
 15            } else {
 16                SRHTLoadingStateView(message: "Loading pastes…")
 17            }
 18        }
 19        .navigationTitle("Pastes")
 20        .toolbar {
 21            if viewModel != nil {
 22                ToolbarItem(placement: .topBarTrailing) {
 23                    Button {
 24                        showCreatePasteSheet = true
 25                    } label: {
 26                        Image(systemName: "plus")
 27                    }
 28                }
 29            }
 30        }
 31        .sheet(isPresented: $showCreatePasteSheet) {
 32            if let viewModel {
 33                CreatePasteSheet(viewModel: viewModel) { paste in
 34                    showCreatePasteSheet = false
 35                    createdPaste = paste
 36                }
 37            }
 38        }
 39        .navigationDestination(isPresented: Binding(
 40            get: { createdPaste != nil },
 41            set: { isPresented in
 42                if !isPresented {
 43                    createdPaste = nil
 44                }
 45            }
 46        )) {
 47            if let createdPaste {
 48                PasteDetailView(
 49                    paste: createdPaste,
 50                    onUpdated: { updated in
 51                        viewModel?.upsertPaste(updated)
 52                    },
 53                    onDeleted: { id in
 54                        viewModel?.removePaste(id: id)
 55                    }
 56                )
 57            }
 58        }
 59        .task {
 60            if viewModel == nil {
 61                let vm = PasteListViewModel(service: PasteService(client: appState.client))
 62                viewModel = vm
 63                await vm.loadPastes()
 64            }
 65        }
 66    }
 67
 68    @ViewBuilder
 69    private func content(_ viewModel: PasteListViewModel) -> some View {
 70        @Bindable var vm = viewModel
 71
 72        List {
 73            ForEach(viewModel.filteredPastes) { paste in
 74                NavigationLink(value: paste) {
 75                    PasteRowView(paste: paste)
 76                        .equatable()
 77                }
 78                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 79                    if swipeActionsEnabled {
 80                        Button {
 81                            Task { await viewModel.cycleVisibility(for: paste) }
 82                        } label: {
 83                            Label(
 84                                nextVisibilityLabel(for: paste.visibility),
 85                                systemImage: nextVisibilityIcon(for: paste.visibility)
 86                            )
 87                        }
 88                        .tint(nextVisibilityColor(for: paste.visibility))
 89                    }
 90                }
 91                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
 92                    if swipeActionsEnabled {
 93                        Button {
 94                            pasteToDelete = paste
 95                        } label: {
 96                            Label("Delete", systemImage: "trash")
 97                        }
 98                        .tint(.red)
 99                    }
100                }
101                .task {
102                    await viewModel.loadMoreIfNeeded(currentItem: paste)
103                }
104            }
105            .themedRow()
106
107            if viewModel.isLoadingMore {
108                HStack {
109                    Spacer()
110                    ProgressView()
111                    Spacer()
112                }
113                .listRowSeparator(.hidden)
114                .themedRow()
115            }
116        }
117        .themedList()
118        .listStyle(.plain)
119        .searchable(
120            text: $vm.searchText,
121            placement: .navigationBarDrawer(displayMode: .always),
122            prompt: "Search pastes"
123        )
124        .overlay {
125            if viewModel.isLoading, viewModel.pastes.isEmpty {
126                SRHTLoadingStateView(message: "Loading pastes…")
127            } else if let error = viewModel.error, viewModel.pastes.isEmpty {
128                SRHTErrorStateView(
129                    title: "Couldn't Load Pastes",
130                    message: error,
131                    retryAction: { await viewModel.loadPastes() }
132                )
133            } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
134                ContentUnavailableView.search(text: viewModel.searchText)
135            } else if viewModel.pastes.isEmpty {
136                ContentUnavailableView(
137                    "No Pastes",
138                    systemImage: "doc.on.clipboard",
139                    description: Text("Your pastes will appear here.")
140                )
141            }
142        }
143        .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
144            await viewModel.loadPastes()
145        }
146        .srhtErrorBanner(error: $vm.error)
147        .alert("Delete Paste?", isPresented: Binding(
148            get: { pasteToDelete != nil },
149            set: { if !$0 { pasteToDelete = nil } }
150        )) {
151            Button("Cancel", role: .cancel) {
152                pasteToDelete = nil
153            }
154            Button("Delete", role: .destructive) {
155                if let paste = pasteToDelete {
156                    pasteToDelete = nil
157                    Task {
158                        await viewModel.deletePaste(paste)
159                    }
160                }
161            }
162        } message: {
163            Text("This paste will be permanently deleted from SourceHut.")
164        }
165        .refreshable {
166            await viewModel.loadPastes()
167        }
168        .navigationDestination(for: Paste.self) { paste in
169            PasteDetailView(
170                paste: paste,
171                onUpdated: { updated in
172                    viewModel.upsertPaste(updated)
173                },
174                onDeleted: { id in
175                    viewModel.removePaste(id: id)
176                }
177            )
178        }
179    }
180
181    private func nextVisibilityLabel(for visibility: Visibility) -> String {
182        switch visibility {
183        case .publicVisibility:
184            return "Make Unlisted"
185        case .unlisted:
186            return "Make Private"
187        case .privateVisibility:
188            return "Make Public"
189        }
190    }
191
192    private func nextVisibilityIcon(for visibility: Visibility) -> String {
193        switch visibility {
194        case .publicVisibility:
195            return "eye.slash"
196        case .unlisted:
197            return "lock"
198        case .privateVisibility:
199            return "globe"
200        }
201    }
202
203    private func nextVisibilityColor(for visibility: Visibility) -> Color {
204        switch visibility {
205        case .publicVisibility:
206            return .orange
207        case .unlisted:
208            return .red
209        case .privateVisibility:
210            return .green
211        }
212    }
213}
214
215private struct PasteRowView: View, Equatable {
216    let paste: Paste
217
218    var body: some View {
219        HStack(alignment: .top, spacing: 12) {
220            Image(systemName: "doc.text")
221                .foregroundStyle(.secondary)
222                .frame(width: 20)
223
224            VStack(alignment: .leading, spacing: 4) {
225                Text(primaryTitle)
226                    .font(.subheadline.weight(.medium))
227                    .lineLimit(1)
228
229                Text(secondaryLine)
230                    .font(.caption)
231                    .foregroundStyle(.secondary)
232                    .lineLimit(2)
233
234                HStack(spacing: 8) {
235                    VisibilityBadge(visibility: paste.visibility)
236                    Text("")
237                        .foregroundStyle(.tertiary)
238                    Text(paste.created.relativeDescription)
239                        .foregroundStyle(.tertiary)
240                }
241                .font(.caption2)
242            }
243        }
244        .padding(.vertical, 2)
245    }
246
247    private var primaryTitle: String {
248        if let filename = paste.files.first?.filename, !filename.isEmpty {
249            return filename
250        }
251        return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
252    }
253
254    private var secondaryLine: String {
255        var parts: [String] = [paste.user.canonicalName]
256        if paste.files.count > 1 {
257            parts.append("\(paste.files.count) files")
258        } else {
259            parts.append("1 file")
260        }
261        if let firstHash = paste.files.first?.hash {
262            parts.append(String(firstHash.prefix(8)))
263        }
264        return parts.joined(separator: "")
265    }
266}
267
268private struct CreatePasteSheet: View {
269    let viewModel: PasteListViewModel
270    let onCreated: (Paste) -> Void
271
272    @Environment(\.dismiss) private var dismiss
273    @State private var files = [PasteUploadDraft()]
274    @State private var visibility: Visibility = .unlisted
275
276    var body: some View {
277        NavigationStack {
278            Form {
279                Section("Files") {
280                    ForEach($files) { fileBinding in
281                        VStack(alignment: .leading, spacing: 8) {
282                            TextField("Filename (optional)", text: fileBinding.filename)
283                                .autocorrectionDisabled()
284                                .textInputAutocapitalization(.never)
285
286                            ZStack(alignment: .topLeading) {
287                                if fileBinding.wrappedValue.contents.isEmpty {
288                                    Text("Paste contents")
289                                        .foregroundStyle(.tertiary)
290                                        .padding(.top, 8)
291                                        .padding(.leading, 5)
292                                        .allowsHitTesting(false)
293                                }
294
295                                TextEditor(text: fileBinding.contents)
296                                    .font(.system(.body, design: .monospaced))
297                                    .frame(minHeight: 180)
298                            }
299                        }
300                        .padding(.vertical, 4)
301                    }
302                    .onDelete { offsets in
303                        files.remove(atOffsets: offsets)
304                        if files.isEmpty {
305                            files = [PasteUploadDraft()]
306                        }
307                    }
308                    .themedRow()
309
310                    Button {
311                        files.append(PasteUploadDraft())
312                    } label: {
313                        Label("Add File", systemImage: "plus")
314                    }
315                    .themedRow()
316                }
317
318                Section("Visibility") {
319                    Picker("Visibility", selection: $visibility) {
320                        Text("Public").tag(Visibility.publicVisibility)
321                        Text("Unlisted").tag(Visibility.unlisted)
322                        Text("Private").tag(Visibility.privateVisibility)
323                    }
324                    .themedRow()
325                }
326
327                Section {
328                    Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
329                        .font(.footnote)
330                        .foregroundStyle(.secondary)
331                        .themedRow()
332                }
333
334                if let error = viewModel.error {
335                    Section {
336                        Label(error, systemImage: "exclamationmark.triangle.fill")
337                            .foregroundStyle(.red)
338                            .themedRow()
339                    }
340                }
341            }
342            .themedList()
343            .navigationTitle("New Paste")
344            .navigationBarTitleDisplayMode(.inline)
345            .toolbar {
346                ToolbarItem(placement: .cancellationAction) {
347                    Button("Cancel") { dismiss() }
348                }
349                ToolbarItem(placement: .confirmationAction) {
350                    Button {
351                        Task {
352                            if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
353                                onCreated(paste)
354                            }
355                        }
356                    } label: {
357                        if viewModel.isCreatingPaste {
358                            ProgressView()
359                                .controlSize(.small)
360                        } else {
361                            Text("Create Paste")
362                        }
363                    }
364                    .disabled(!hasValidContent || viewModel.isCreatingPaste)
365                }
366            }
367        }
368    }
369
370    private var hasValidContent: Bool {
371        files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
372    }
373}