krz/hutch

an ios client for sourcehut

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

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

  1import SwiftUI
  2
  3struct InboxView: View {
  4    @Environment(AppState.self) private var appState
  5    @Environment(\.scenePhase) private var scenePhase
  6    @State private var viewModel: InboxViewModel?
  7    @State private var selectedThreadID: InboxThreadSummary.ID?
  8    @State private var selectedThreadSnapshot: InboxThreadSummary?
  9    @State private var isShowingThreadDetail = false
 10
 11    var body: some View {
 12        Group {
 13            if let viewModel {
 14                listContent(viewModel)
 15            } else {
 16                SRHTLoadingStateView(message: "Loading inbox…")
 17            }
 18        }
 19        .navigationTitle("Inbox")
 20        .task {
 21            let vm: InboxViewModel
 22            if let viewModel {
 23                vm = viewModel
 24            } else {
 25                let newViewModel = InboxViewModel(client: appState.client)
 26                viewModel = newViewModel
 27                vm = newViewModel
 28            }
 29
 30            await vm.loadThreads()
 31        }
 32        .onChange(of: scenePhase) { _, newPhase in
 33            guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return }
 34            Task {
 35                await viewModel.loadThreads()
 36            }
 37        }
 38    }
 39
 40    @ViewBuilder
 41    private func listContent(_ viewModel: InboxViewModel) -> some View {
 42        @Bindable var vm = viewModel
 43
 44        List {
 45            Section {
 46                Picker("Filter", selection: $vm.filter) {
 47                    ForEach(InboxThreadFilter.allCases, id: \.self) { filter in
 48                        Text(filter.rawValue).tag(filter)
 49                    }
 50                }
 51                .pickerStyle(.segmented)
 52                .listRowBackground(Color.clear)
 53                .listRowInsets(EdgeInsets())
 54            }
 55
 56            ForEach(viewModel.filteredThreads) { thread in
 57                Button {
 58                    selectThread(thread)
 59                } label: {
 60                    InboxThreadRow(thread: thread)
 61                }
 62                .buttonStyle(.plain)
 63                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 64                    readStateAction(for: thread, in: viewModel)
 65                }
 66                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
 67                    readStateAction(for: thread, in: viewModel)
 68                }
 69            }
 70        }
 71        .searchable(
 72            text: $vm.searchText,
 73            placement: .navigationBarDrawer(displayMode: .always),
 74            prompt: "Search inbox"
 75        )
 76        .listStyle(.plain)
 77        .toolbar {
 78            ToolbarItem(placement: .topBarTrailing) {
 79                if viewModel.hasUnreadThreads {
 80                    Button("Mark All Read") {
 81                        withAnimation(.easeInOut(duration: 0.2)) {
 82                            viewModel.markAllThreadsRead()
 83                        }
 84                    }
 85                }
 86            }
 87        }
 88        .overlay {
 89            if viewModel.isLoading, viewModel.threads.isEmpty {
 90                SRHTLoadingStateView(message: "Loading inbox…")
 91            } else if let error = viewModel.error, viewModel.threads.isEmpty {
 92                SRHTErrorStateView(
 93                    title: "Failed to load inbox",
 94                    message: error,
 95                    retryAction: { await viewModel.loadThreads() }
 96                )
 97            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
 98                ContentUnavailableView.search(text: viewModel.searchText)
 99            } else if viewModel.threads.isEmpty, viewModel.error == nil {
100                ContentUnavailableView(
101                    "Inbox Zero",
102                    systemImage: "tray",
103                    description: Text("You're up to date.")
104                )
105            }
106        }
107        .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
108            await viewModel.loadThreads()
109        }
110        .srhtErrorBanner(error: $vm.error)
111        .refreshable {
112            await viewModel.loadThreads()
113        }
114        .onChange(of: viewModel.threads) { _, threads in
115            syncSelectedThreadSnapshot(with: threads)
116        }
117        .navigationDestination(isPresented: Binding(
118            get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil },
119            set: { isPresented in
120                if !isPresented {
121                    clearSelection()
122                }
123                isShowingThreadDetail = isPresented
124            }
125        )) {
126            if let thread = selectedThread(for: viewModel) {
127                ThreadDetailView(thread: thread) {
128                    viewModel.markThreadRead(thread)
129                }
130                .onAppear {
131                    cacheSelectedThread(thread)
132                }
133                .onDisappear {
134                    handleThreadDetailDisappear(for: thread.id)
135                }
136            } else {
137                ContentUnavailableView(
138                    "Thread Unavailable",
139                    systemImage: "tray",
140                    description: Text("This thread could not be restored.")
141                )
142            }
143        }
144    }
145
146    @ViewBuilder
147    private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
148        Button {
149            withAnimation(.easeInOut(duration: 0.2)) {
150                viewModel.markThreadRead(thread)
151            }
152        } label: {
153            Label("Mark as Read", systemImage: "envelope.open")
154        }
155        .tint(.blue)
156    }
157
158    private func selectThread(_ thread: InboxThreadSummary) {
159        cacheSelectedThread(thread)
160        isShowingThreadDetail = true
161    }
162
163    private func cacheSelectedThread(_ thread: InboxThreadSummary) {
164        selectedThreadID = thread.id
165        selectedThreadSnapshot = thread
166    }
167
168    private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? {
169        guard let selectedThreadID else { return selectedThreadSnapshot }
170        return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil)
171    }
172
173    private func handleThreadDetailDisappear(for threadID: String) {
174        let isActiveSelection = selectedThreadID == threadID
175        guard isActiveSelection else { return }
176        clearSelection()
177    }
178
179    private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) {
180        guard let selectedThreadID else { return }
181        guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return }
182        selectedThreadSnapshot = updatedThread
183    }
184
185    private func clearSelection() {
186        selectedThreadID = nil
187        selectedThreadSnapshot = nil
188        isShowingThreadDetail = false
189    }
190}
191
192struct InboxThreadRow: View {
193    let thread: InboxThreadSummary
194
195    var body: some View {
196        HStack(alignment: .top, spacing: 12) {
197            Circle()
198                .fill(thread.isUnread ? .blue : .clear)
199                .frame(width: 8, height: 8)
200                .padding(.top, 6)
201
202            VStack(alignment: .leading, spacing: 4) {
203                Text(thread.displaySubject)
204                    .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
205                    .lineLimit(2)
206
207                HStack(spacing: 8) {
208                    if thread.containsPatch {
209                        Image(systemName: "arrow.triangle.branch")
210                            .font(.caption)
211                            .foregroundStyle(.secondary)
212                    }
213
214                    Text(thread.metadataLine)
215                        .font(.caption)
216                        .foregroundStyle(.secondary)
217                        .lineLimit(1)
218                }
219            }
220
221            Spacer(minLength: 8)
222
223            Text(thread.lastActivityAt.relativeDescription)
224                .font(.caption)
225                .foregroundStyle(.tertiary.opacity(0.7))
226                .lineLimit(1)
227        }
228        .padding(.vertical, 2)
229    }
230}