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