krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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
7 var body: some View {
8 Group {
9 if let viewModel {
10 listContent(viewModel)
11 } else {
12 SRHTLoadingStateView(message: "Loading inbox…")
13 }
14 }
15 .navigationTitle("Inbox")
16 .task {
17 if viewModel == nil {
18 let vm = InboxViewModel(client: appState.client)
19 viewModel = vm
20 await vm.loadThreads()
21 }
22 }
23 }
24
25 @ViewBuilder
26 private func listContent(_ viewModel: InboxViewModel) -> some View {
27 @Bindable var vm = viewModel
28
29 List {
30 ForEach(viewModel.threads) { thread in
31 NavigationLink(value: thread) {
32 InboxThreadRow(thread: thread)
33 }
34 .swipeActions(edge: .leading, allowsFullSwipe: true) {
35 readStateAction(for: thread, in: viewModel)
36 }
37 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
38 readStateAction(for: thread, in: viewModel)
39 }
40 }
41 }
42 .listStyle(.plain)
43 .overlay {
44 if viewModel.isLoading, viewModel.threads.isEmpty {
45 SRHTLoadingStateView(message: "Loading inbox…")
46 } else if let error = viewModel.error, viewModel.threads.isEmpty {
47 SRHTErrorStateView(
48 title: "Failed to load inbox",
49 message: error,
50 retryAction: { await viewModel.loadThreads() }
51 )
52 } else if viewModel.threads.isEmpty, viewModel.error == nil {
53 ContentUnavailableView(
54 "Inbox Zero",
55 systemImage: "tray",
56 description: Text("Unread threads will appear here.")
57 )
58 }
59 }
60 .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
61 await viewModel.loadThreads()
62 }
63 .srhtErrorBanner(error: $vm.error)
64 .refreshable {
65 await viewModel.loadThreads()
66 }
67 .navigationDestination(for: InboxThreadSummary.self) { thread in
68 ThreadDetailView(thread: thread) {
69 viewModel.markThreadRead(thread)
70 }
71 }
72 }
73
74 @ViewBuilder
75 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
76 Button {
77 withAnimation(.easeInOut(duration: 0.2)) {
78 viewModel.markThreadRead(thread)
79 }
80 } label: {
81 Label("Mark as Read", systemImage: "envelope.open")
82 }
83 .tint(.blue)
84 }
85}
86
87struct InboxThreadRow: View {
88 let thread: InboxThreadSummary
89
90 var body: some View {
91 HStack(alignment: .top, spacing: 12) {
92 Circle()
93 .fill(thread.isUnread ? .blue : .clear)
94 .frame(width: 8, height: 8)
95 .padding(.top, 6)
96
97 VStack(alignment: .leading, spacing: 4) {
98 Text(thread.displaySubject)
99 .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
100 .lineLimit(2)
101
102 HStack(spacing: 8) {
103 if thread.containsPatch {
104 Image(systemName: "arrow.triangle.branch")
105 .font(.caption)
106 .foregroundStyle(.secondary)
107 }
108
109 Text(thread.metadataLine)
110 .font(.caption)
111 .foregroundStyle(.secondary)
112 .lineLimit(1)
113 }
114 }
115
116 Spacer(minLength: 8)
117
118 Text(thread.lastActivityAt.relativeDescription)
119 .font(.caption)
120 .foregroundStyle(.tertiary.opacity(0.7))
121 .lineLimit(1)
122 }
123 .padding(.vertical, 2)
124 }
125}