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}