summaryrefslogtreecommitdiff
path: root/Hutch/Views/Inbox
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 01:53:46 -0500
committerChristian Cleberg <[email protected]>2026-03-19 01:53:46 -0500
commit1e3c748119c6e9eec27f02146f17ea0302ff648a (patch)
tree6a5a7c6d22a9022b1b3daa3710768d12981ca0ed /Hutch/Views/Inbox
parent441b69afae30ee6b662be38004fd7b5de1e47302 (diff)
downloadhutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.gz
hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.bz2
hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.zip
feat: add inbox threads and move builds under more
Diffstat (limited to 'Hutch/Views/Inbox')
-rw-r--r--Hutch/Views/Inbox/InboxView.swift23
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift76
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift10
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift116
4 files changed, 171 insertions, 54 deletions
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index e5f3619..602f8d9 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -31,6 +31,12 @@ struct InboxView: View {
NavigationLink(value: thread) {
InboxThreadRow(thread: thread)
}
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
}
}
.listStyle(.plain)
@@ -39,7 +45,7 @@ struct InboxView: View {
SRHTLoadingStateView(message: "Loading inbox…")
} else if let error = viewModel.error, viewModel.threads.isEmpty {
SRHTErrorStateView(
- title: "Couldn't Load Threads",
+ title: "Failed to load inbox",
message: error,
retryAction: { await viewModel.loadThreads() }
)
@@ -64,6 +70,21 @@ struct InboxView: View {
}
}
}
+
+ @ViewBuilder
+ private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ viewModel.toggleThreadReadState(thread)
+ }
+ } label: {
+ Label(
+ thread.isUnread ? "Mark as Read" : "Mark as Unread",
+ systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
+ )
+ }
+ .tint(thread.isUnread ? .blue : .gray)
+ }
}
private struct InboxThreadRow: View {
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 808a5ef..9211b40 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -134,33 +134,34 @@ final class InboxViewModel {
return lhs.lastActivityAt > rhs.lastActivityAt
}
} catch {
- threads = []
- self.error = error.localizedDescription
+ inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
+ self.error = "Failed to load inbox"
}
}
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.id)
- guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
- let current = threads[index]
- threads[index] = InboxThreadSummary(
- rootEmailID: current.rootEmailID,
- rootMessageID: current.rootMessageID,
- threadRootEmailIDs: current.threadRootEmailIDs,
- threadRootMessageIDs: current.threadRootMessageIDs,
- listID: current.listID,
- listRID: current.listRID,
- listName: current.listName,
- listOwner: current.listOwner,
- subject: current.subject,
- latestSender: current.latestSender,
- lastActivityAt: current.lastActivityAt,
- messageCount: current.messageCount,
- repo: current.repo,
- containsPatch: current.containsPatch,
- isUnread: false
+ inboxListLogger.debug(
+ "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
)
+ updateThread(thread, isUnread: false)
+ }
+
+ func markThreadUnread(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markUnread(for: thread.id)
+ inboxListLogger.debug(
+ "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
+ )
+ updateThread(thread, isUnread: true)
+ }
+
+ func toggleThreadReadState(_ thread: InboxThreadSummary) {
+ if thread.isUnread {
+ markThreadRead(thread)
+ } else {
+ markThreadUnread(thread)
+ }
}
private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
@@ -211,7 +212,7 @@ final class InboxViewModel {
do {
return (try await self.fetchThreads(for: mailingList), nil)
} catch {
- return ([], error.localizedDescription)
+ return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
}
}
}
@@ -229,6 +230,9 @@ final class InboxViewModel {
summaries.append(contentsOf: batchResult.0)
failureMessages.append(contentsOf: batchResult.1)
+ for failure in batchResult.1 {
+ inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
+ }
startIndex = endIndex
}
@@ -248,11 +252,15 @@ final class InboxViewModel {
)
return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
- let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
+ let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
+ let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
inboxListLogger.debug(
"Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
)
+ inboxListLogger.debug(
+ "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
+ )
return InboxThreadSummary(
rootEmailID: thread.root.id,
rootMessageID: thread.root.messageID,
@@ -268,7 +276,7 @@ final class InboxViewModel {
messageCount: thread.replies + 1,
repo: Self.deriveRepositoryName(from: mailingList.name),
containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
- isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ isUnread: isUnread
)
}
}
@@ -318,6 +326,28 @@ final class InboxViewModel {
}
}
+ private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ threads[index] = InboxThreadSummary(
+ rootEmailID: current.rootEmailID,
+ rootMessageID: current.rootMessageID,
+ threadRootEmailIDs: current.threadRootEmailIDs,
+ threadRootMessageIDs: current.threadRootMessageIDs,
+ listID: current.listID,
+ listRID: current.listRID,
+ listName: current.listName,
+ listOwner: current.listOwner,
+ subject: current.subject,
+ latestSender: current.latestSender,
+ lastActivityAt: current.lastActivityAt,
+ messageCount: current.messageCount,
+ repo: current.repo,
+ containsPatch: current.containsPatch,
+ isUnread: isUnread
+ )
+ }
+
private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
var seen = Set<String>()
return mailingLists.filter { mailingList in
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 3c42e7f..7677698 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -93,6 +93,14 @@ struct ThreadDetailView: View {
.padding(.vertical, 4)
}
+ if let partialWarning = viewModel.partialWarning {
+ Section {
+ Text(partialWarning)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
ForEach(thread.messages) { message in
InboxMessageRow(message: message)
}
@@ -111,7 +119,7 @@ struct ThreadDetailView: View {
SRHTLoadingStateView(message: "Loading thread…")
} else if let error = viewModel.error, viewModel.thread == nil {
SRHTErrorStateView(
- title: "Couldn't Load Thread",
+ title: "Failed to load thread",
message: error,
retryAction: { await viewModel.loadThread() }
)
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
index 10a5b59..c042fba 100644
--- a/Hutch/Views/Inbox/ThreadViewModel.swift
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -61,6 +61,7 @@ final class ThreadViewModel {
private(set) var thread: InboxThreadDetail?
private(set) var isLoading = false
var error: String?
+ var partialWarning: String?
var composeDraft: MailComposeDraft?
private let summary: InboxThreadSummary
@@ -168,6 +169,7 @@ final class ThreadViewModel {
guard !isLoading else { return }
isLoading = true
error = nil
+ partialWarning = nil
defer { isLoading = false }
inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
@@ -187,18 +189,27 @@ final class ThreadViewModel {
)
var messagesByID: [Int: InboxMessage] = [:]
+ var hadPartialReplyFailure = false
+
for payload in threadPayloads {
guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
continue
}
messagesByID[rootMessage.id] = rootMessage
- let descendantMessages = try await fetchAllDescendantMessages(
- initialPayload: payload,
- candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
- )
- for message in descendantMessages {
- messagesByID[message.id] = message
+ do {
+ let descendantMessages = try await fetchAllDescendantMessages(
+ initialPayload: payload,
+ candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
+ )
+ for message in descendantMessages {
+ messagesByID[message.id] = message
+ }
+ } catch {
+ hadPartialReplyFailure = true
+ inboxLogger.error(
+ "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
+ )
}
}
@@ -223,9 +234,15 @@ final class ThreadViewModel {
messageCount: max(messages.count, summary.messageCount ?? 0),
messages: messages
)
+ if hadPartialReplyFailure {
+ partialWarning = "Some replies could not be loaded."
+ }
} catch {
- thread = nil
- self.error = error.localizedDescription
+ if thread == nil {
+ self.error = "Failed to load thread"
+ } else {
+ self.error = error.localizedDescription
+ }
inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
@@ -318,18 +335,27 @@ final class ThreadViewModel {
variables["cursor"] = threadCursor
}
- let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
- client: client,
- query: Self.threadDetailQuery,
- variables: {
- var variables = variables
- variables["descCursor"] = nil as String?
- return variables
- }()
- )
+ let response: InboxThreadDetailResponse
+ do {
+ response = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadDetailQuery,
+ variables: {
+ var variables = variables
+ variables["descCursor"] = nil as String?
+ return variables
+ }()
+ )
+ } catch {
+ if Self.isRecoverableNoRows(error) {
+ inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ return nil
+ }
+ throw error
+ }
guard let threadPage = response.list?.threads else {
- throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+ return nil
}
let candidates = threadPage.results.map { payload in
@@ -389,15 +415,26 @@ final class ThreadViewModel {
candidateMessageIDs: [String]
) async throws -> InboxThreadMessagesPage? {
for messageID in candidateMessageIDs {
- let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
- client: client,
- query: Self.threadByMessageIDQuery,
- variables: [
- "rid": summary.listRID,
- "messageID": messageID,
- "descCursor": cursor
- ]
- )
+ let response: InboxThreadLookupResponse
+ do {
+ response = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadByMessageIDQuery,
+ variables: [
+ "rid": summary.listRID,
+ "messageID": messageID,
+ "descCursor": cursor
+ ]
+ )
+ } catch {
+ if Self.isRecoverableNoRows(error) {
+ inboxLogger.error(
+ "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
+ )
+ continue
+ }
+ throw error
+ }
if let descendants = response.list?.message?.thread?.descendants {
return descendants
@@ -533,7 +570,8 @@ final class ThreadViewModel {
}
private static func sanitizedDisplayBody(from body: String) -> String {
- let lines = body.components(separatedBy: .newlines)
+ let normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
var headerCount = 0
var blankLineIndex: Int?
@@ -551,7 +589,7 @@ final class ThreadViewModel {
}
guard headerCount >= 2, let blankLineIndex else {
- return body
+ return stripLeadingFromLineIfPresent(in: normalizedBody)
}
return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
@@ -644,6 +682,19 @@ final class ThreadViewModel {
.replacingOccurrences(of: "\r", with: "\n")
}
+ private static func stripLeadingFromLineIfPresent(in body: String) -> String {
+ let lines = body.components(separatedBy: "\n")
+ guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
+ return body
+ }
+
+ var remainingLines = Array(lines.dropFirst())
+ if let nextLine = remainingLines.first, nextLine.isEmpty {
+ remainingLines.removeFirst()
+ }
+ return remainingLines.joined(separator: "\n")
+ }
+
private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
let prefix = "\(headerName):"
let lines = body.components(separatedBy: .newlines)
@@ -699,4 +750,11 @@ final class ThreadViewModel {
}
return payload
}
+
+ private static func isRecoverableNoRows(_ error: Error) -> Bool {
+ guard case let SRHTError.graphQLErrors(errors) = error else {
+ return false
+ }
+ return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
+ }
}