krz/hutch

an ios client for sourcehut

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

v2: Hutch/Views/Inbox/InboxView.swift · raw

  1import SwiftUI
  2
  3struct InboxView: View {
  4    @Environment(AppState.self) private var appState
  5    @State private var viewModel: InboxViewModel?
  6
  7    var body: some View {
  8        Group {
  9            if let viewModel {
 10                listContent(viewModel)
 11            } else {
 12                SRHTLoadingStateView(message: "Loading inbox…")
 13            }
 14        }
 15        .navigationTitle("Inbox")
 16        .task {
 17            if viewModel == nil {
 18                let vm = InboxViewModel(client: appState.client)
 19                viewModel = vm
 20                await vm.loadThreads()
 21            }
 22        }
 23    }
 24
 25    @ViewBuilder
 26    private func listContent(_ viewModel: InboxViewModel) -> some View {
 27        @Bindable var vm = viewModel
 28
 29        List {
 30            ForEach(viewModel.threads) { thread in
 31                NavigationLink(value: thread) {
 32                    InboxThreadRow(thread: thread)
 33                }
 34                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 35                    readStateAction(for: thread, in: viewModel)
 36                }
 37                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
 38                    readStateAction(for: thread, in: viewModel)
 39                }
 40            }
 41        }
 42        .listStyle(.plain)
 43        .overlay {
 44            if viewModel.isLoading, viewModel.threads.isEmpty {
 45                SRHTLoadingStateView(message: "Loading inbox…")
 46            } else if let error = viewModel.error, viewModel.threads.isEmpty {
 47                SRHTErrorStateView(
 48                    title: "Failed to load inbox",
 49                    message: error,
 50                    retryAction: { await viewModel.loadThreads() }
 51                )
 52            } else if viewModel.threads.isEmpty, viewModel.error == nil {
 53                ContentUnavailableView(
 54                    "Inbox Zero",
 55                    systemImage: "tray",
 56                    description: Text("Unread threads will appear here.")
 57                )
 58            }
 59        }
 60        .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
 61            await viewModel.loadThreads()
 62        }
 63        .srhtErrorBanner(error: $vm.error)
 64        .refreshable {
 65            await viewModel.loadThreads()
 66        }
 67        .navigationDestination(for: InboxThreadSummary.self) { thread in
 68            ThreadDetailView(thread: thread) {
 69                viewModel.markThreadRead(thread)
 70            }
 71        }
 72    }
 73
 74    @ViewBuilder
 75    private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
 76        Button {
 77            withAnimation(.easeInOut(duration: 0.2)) {
 78                viewModel.markThreadRead(thread)
 79            }
 80        } label: {
 81            Label("Mark as Read", systemImage: "envelope.open")
 82        }
 83        .tint(.blue)
 84    }
 85}
 86
 87struct InboxThreadRow: View {
 88    let thread: InboxThreadSummary
 89
 90    var body: some View {
 91        HStack(alignment: .top, spacing: 12) {
 92            Circle()
 93                .fill(thread.isUnread ? .blue : .clear)
 94                .frame(width: 8, height: 8)
 95                .padding(.top, 6)
 96
 97            VStack(alignment: .leading, spacing: 4) {
 98                Text(thread.displaySubject)
 99                    .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
100                    .lineLimit(2)
101
102                HStack(spacing: 8) {
103                    if thread.containsPatch {
104                        Image(systemName: "arrow.triangle.branch")
105                            .font(.caption)
106                            .foregroundStyle(.secondary)
107                    }
108
109                    Text(thread.metadataLine)
110                        .font(.caption)
111                        .foregroundStyle(.secondary)
112                        .lineLimit(1)
113                }
114            }
115
116            Spacer(minLength: 8)
117
118            Text(thread.lastActivityAt.relativeDescription)
119                .font(.caption)
120                .foregroundStyle(.tertiary.opacity(0.7))
121                .lineLimit(1)
122        }
123        .padding(.vertical, 2)
124    }
125}