krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.11.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 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}