krz/hutch

an ios client for sourcehut

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

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