krz/hutch

an ios client for sourcehut

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

v2.1: 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    @State private var selectedThreadID: InboxThreadSummary.ID?
  7    @State private var selectedThreadSnapshot: InboxThreadSummary?
  8    @State private var isShowingThreadDetail = false
  9
 10    var body: some View {
 11        Group {
 12            if let viewModel {
 13                listContent(viewModel)
 14            } else {
 15                SRHTLoadingStateView(message: "Loading inbox…")
 16            }
 17        }
 18        .navigationTitle("Inbox")
 19        .task {
 20            if viewModel == nil {
 21                let vm = InboxViewModel(client: appState.client)
 22                viewModel = vm
 23                await vm.loadThreads()
 24            }
 25        }
 26    }
 27
 28    @ViewBuilder
 29    private func listContent(_ viewModel: InboxViewModel) -> some View {
 30        @Bindable var vm = viewModel
 31
 32        List {
 33            ForEach(viewModel.threads) { thread in
 34                Button {
 35                    selectThread(thread)
 36                } label: {
 37                    InboxThreadRow(thread: thread)
 38                }
 39                .buttonStyle(.plain)
 40                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 41                    readStateAction(for: thread, in: viewModel)
 42                }
 43                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
 44                    readStateAction(for: thread, in: viewModel)
 45                }
 46            }
 47        }
 48        .listStyle(.plain)
 49        .overlay {
 50            if viewModel.isLoading, viewModel.threads.isEmpty {
 51                SRHTLoadingStateView(message: "Loading inbox…")
 52            } else if let error = viewModel.error, viewModel.threads.isEmpty {
 53                SRHTErrorStateView(
 54                    title: "Failed to load inbox",
 55                    message: error,
 56                    retryAction: { await viewModel.loadThreads() }
 57                )
 58            } else if viewModel.threads.isEmpty, viewModel.error == nil {
 59                ContentUnavailableView(
 60                    "Inbox Zero",
 61                    systemImage: "tray",
 62                    description: Text("Unread threads will appear here.")
 63                )
 64            }
 65        }
 66        .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
 67            await viewModel.loadThreads()
 68        }
 69        .srhtErrorBanner(error: $vm.error)
 70        .refreshable {
 71            await viewModel.loadThreads()
 72        }
 73        .onChange(of: viewModel.threads) { _, threads in
 74            syncSelectedThreadSnapshot(with: threads)
 75        }
 76        .navigationDestination(isPresented: Binding(
 77            get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil },
 78            set: { isPresented in
 79                if !isPresented {
 80                    clearSelection()
 81                }
 82                isShowingThreadDetail = isPresented
 83            }
 84        )) {
 85            if let thread = selectedThread(for: viewModel) {
 86                ThreadDetailView(thread: thread) {
 87                    viewModel.markThreadRead(thread)
 88                }
 89                .onAppear {
 90                    cacheSelectedThread(thread)
 91                }
 92                .onDisappear {
 93                    handleThreadDetailDisappear(for: thread.id)
 94                }
 95            } else {
 96                ContentUnavailableView(
 97                    "Thread Unavailable",
 98                    systemImage: "tray",
 99                    description: Text("This thread could not be restored.")
100                )
101            }
102        }
103    }
104
105    @ViewBuilder
106    private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
107        Button {
108            withAnimation(.easeInOut(duration: 0.2)) {
109                viewModel.markThreadRead(thread)
110            }
111        } label: {
112            Label("Mark as Read", systemImage: "envelope.open")
113        }
114        .tint(.blue)
115    }
116
117    private func selectThread(_ thread: InboxThreadSummary) {
118        cacheSelectedThread(thread)
119        isShowingThreadDetail = true
120    }
121
122    private func cacheSelectedThread(_ thread: InboxThreadSummary) {
123        selectedThreadID = thread.id
124        selectedThreadSnapshot = thread
125    }
126
127    private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? {
128        guard let selectedThreadID else { return selectedThreadSnapshot }
129        return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil)
130    }
131
132    private func handleThreadDetailDisappear(for threadID: String) {
133        let isActiveSelection = selectedThreadID == threadID
134        guard isActiveSelection else { return }
135        clearSelection()
136    }
137
138    private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) {
139        guard let selectedThreadID else { return }
140        guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return }
141        selectedThreadSnapshot = updatedThread
142    }
143
144    private func clearSelection() {
145        selectedThreadID = nil
146        selectedThreadSnapshot = nil
147        isShowingThreadDetail = false
148    }
149}
150
151struct InboxThreadRow: View {
152    let thread: InboxThreadSummary
153
154    var body: some View {
155        HStack(alignment: .top, spacing: 12) {
156            Circle()
157                .fill(thread.isUnread ? .blue : .clear)
158                .frame(width: 8, height: 8)
159                .padding(.top, 6)
160
161            VStack(alignment: .leading, spacing: 4) {
162                Text(thread.displaySubject)
163                    .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
164                    .lineLimit(2)
165
166                HStack(spacing: 8) {
167                    if thread.containsPatch {
168                        Image(systemName: "arrow.triangle.branch")
169                            .font(.caption)
170                            .foregroundStyle(.secondary)
171                    }
172
173                    Text(thread.metadataLine)
174                        .font(.caption)
175                        .foregroundStyle(.secondary)
176                        .lineLimit(1)
177                }
178            }
179
180            Spacer(minLength: 8)
181
182            Text(thread.lastActivityAt.relativeDescription)
183                .font(.caption)
184                .foregroundStyle(.tertiary.opacity(0.7))
185                .lineLimit(1)
186        }
187        .padding(.vertical, 2)
188    }
189}