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