krz/hutch

an ios client for sourcehut

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

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