summaryrefslogtreecommitdiff
path: root/Hutch/Views/Inbox/InboxView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 01:17:49 -0500
committerChristian Cleberg <[email protected]>2026-03-19 01:17:49 -0500
commit441b69afae30ee6b662be38004fd7b5de1e47302 (patch)
tree610827d41c7e7157290727b69b320f8dce95f08f /Hutch/Views/Inbox/InboxView.swift
parentffc0ef0988b5e7d01951c23af85a1cf026b3da9f (diff)
downloadhutch-441b69afae30ee6b662be38004fd7b5de1e47302.tar.gz
hutch-441b69afae30ee6b662be38004fd7b5de1e47302.tar.bz2
hutch-441b69afae30ee6b662be38004fd7b5de1e47302.zip
feat: add support for reading inbox messages (emails) and replying in-app
Diffstat (limited to 'Hutch/Views/Inbox/InboxView.swift')
-rw-r--r--Hutch/Views/Inbox/InboxView.swift107
1 files changed, 107 insertions, 0 deletions
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..e5f3619
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct InboxView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: InboxViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ }
+ }
+ .navigationTitle("Inbox")
+ .task {
+ if viewModel == nil {
+ let vm = InboxViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: InboxViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: thread) {
+ InboxThreadRow(thread: thread)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Threads",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Threads",
+ systemImage: "tray",
+ description: Text("Patch threads will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .navigationDestination(for: InboxThreadSummary.self) { thread in
+ ThreadDetailView(thread: thread) {
+ viewModel.markThreadRead(thread)
+ }
+ }
+ }
+}
+
+private struct InboxThreadRow: View {
+ let thread: InboxThreadSummary
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Circle()
+ .fill(thread.isUnread ? .blue : .clear)
+ .frame(width: 8, height: 8)
+ .padding(.top, 6)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(thread.displaySubject)
+ .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ if thread.containsPatch {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(thread.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ Text(thread.lastActivityAt.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary.opacity(0.7))
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+}