filter thread list by selected mailbox, fix sidebar selection

- threadSummariesFromDB accepts optional mailboxId filter, uses EXISTS subquery
  to show only threads containing messages in the selected mailbox
- add selectMailbox() and selectPerspective() to MailViewModel for clean
  state transitions between folder view and GTD perspective view
- sidebar highlights selected mailbox/perspective, clears state on switch
- default to INBOX mailbox after first sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:44:41 +01:00
parent 6bd1163c6c
commit 367359abe6
3 changed files with 47 additions and 13 deletions

View File

@@ -168,12 +168,12 @@ final class MailViewModel {
} }
} }
func startObservingThreads(accountId: String) { func startObservingThreads(accountId: String, mailboxId: String? = nil) {
guard let store else { return } guard let store else { return }
threadObservation?.cancel() threadObservation?.cancel()
threadObservation = Task { threadObservation = Task {
do { do {
for try await summaries in store.observeThreadSummaries(accountId: accountId) { for try await summaries in store.observeThreadSummaries(accountId: accountId, mailboxId: mailboxId) {
self.threads = summaries self.threads = summaries
} }
} catch { } catch {
@@ -184,6 +184,22 @@ final class MailViewModel {
} }
} }
func selectMailbox(_ mailbox: MailboxInfo) {
selectedMailbox = mailbox
selectedItem = nil
items = []
guard let accountConfig else { return }
startObservingThreads(accountId: accountConfig.id, mailboxId: mailbox.id)
}
func selectPerspective(_ perspective: Perspective) {
selectedMailbox = nil
selectedThread = nil
messages = []
loadPerspective(perspective)
loadAllPerspectiveCounts()
}
func selectThread(_ thread: ThreadSummary) { func selectThread(_ thread: ThreadSummary) {
selectedThread = thread selectedThread = thread
messageObservation?.cancel() messageObservation?.cancel()
@@ -231,6 +247,10 @@ final class MailViewModel {
// Reload mailboxes after sync (they may have been created on first sync) // Reload mailboxes after sync (they may have been created on first sync)
if let accountConfig { if let accountConfig {
await loadMailboxes(accountId: accountConfig.id) await loadMailboxes(accountId: accountConfig.id)
// On first sync, default to INBOX view
if selectedMailbox == nil, let inbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
selectMailbox(inbox)
}
} }
} catch { } catch {
let desc = "\(error)" let desc = "\(error)"

View File

@@ -9,26 +9,28 @@ struct SidebarView: View {
Section("Perspectives") { Section("Perspectives") {
ForEach(Perspective.allCases) { perspective in ForEach(Perspective.allCases) { perspective in
Button { Button {
viewModel.loadPerspective(perspective) viewModel.selectPerspective(perspective)
} label: { } label: {
Label(perspective.label, systemImage: perspective.systemImage) Label(perspective.label, systemImage: perspective.systemImage)
.badge(viewModel.perspectiveCounts[perspective] ?? 0) .badge(viewModel.perspectiveCounts[perspective] ?? 0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listItemTint(viewModel.selectedPerspective == perspective ? .accentColor : nil) .listItemTint(viewModel.selectedPerspective == perspective && viewModel.selectedMailbox == nil ? .accentColor : nil)
.fontWeight(viewModel.selectedPerspective == perspective ? .semibold : .regular) .fontWeight(viewModel.selectedPerspective == perspective && viewModel.selectedMailbox == nil ? .semibold : .regular)
} }
} }
Section("Mailboxes") { Section("Mailboxes") {
ForEach(viewModel.mailboxes) { mailbox in ForEach(viewModel.mailboxes) { mailbox in
Button { Button {
viewModel.selectedMailbox = mailbox viewModel.selectMailbox(mailbox)
} label: { } label: {
Label(mailbox.name, systemImage: mailbox.systemImage) Label(mailbox.name, systemImage: mailbox.systemImage)
.badge(mailbox.unreadCount) .badge(mailbox.unreadCount)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listItemTint(viewModel.selectedMailbox?.id == mailbox.id ? .accentColor : nil)
.fontWeight(viewModel.selectedMailbox?.id == mailbox.id ? .semibold : .regular)
} }
} }
} }

View File

@@ -16,16 +16,16 @@ extension MailStore {
} }
/// Thread summaries with unread count and latest sender info, ordered by lastDate DESC /// Thread summaries with unread count and latest sender info, ordered by lastDate DESC
public func threadSummaries(accountId: String) throws -> [ThreadSummary] { public func threadSummaries(accountId: String, mailboxId: String? = nil) throws -> [ThreadSummary] {
try dbWriter.read { db in try dbWriter.read { db in
try Self.threadSummariesFromDB(db, accountId: accountId) try Self.threadSummariesFromDB(db, accountId: accountId, mailboxId: mailboxId)
} }
} }
/// Observe thread summaries reactively UI updates automatically on DB change. /// Observe thread summaries reactively UI updates automatically on DB change.
public func observeThreadSummaries(accountId: String) -> AsyncValueObservation<[ThreadSummary]> { public func observeThreadSummaries(accountId: String, mailboxId: String? = nil) -> AsyncValueObservation<[ThreadSummary]> {
ValueObservation.tracking { db -> [ThreadSummary] in ValueObservation.tracking { db -> [ThreadSummary] in
try Self.threadSummariesFromDB(db, accountId: accountId) try Self.threadSummariesFromDB(db, accountId: accountId, mailboxId: mailboxId)
}.values(in: dbWriter) }.values(in: dbWriter)
} }
@@ -44,7 +44,19 @@ extension MailStore {
// MARK: - Internal helpers // MARK: - Internal helpers
static func threadSummariesFromDB(_ db: Database, accountId: String) throws -> [ThreadSummary] { static func threadSummariesFromDB(_ db: Database, accountId: String, mailboxId: String? = nil) throws -> [ThreadSummary] {
let mailboxFilter: String
let arguments: StatementArguments
if let mailboxId {
// Filter threads to only those containing messages in the specified mailbox
mailboxFilter = "AND EXISTS (SELECT 1 FROM threadMessage tm2 JOIN message m2 ON m2.id = tm2.messageId WHERE tm2.threadId = t.id AND m2.mailboxId = ?)"
arguments = [accountId, mailboxId]
} else {
mailboxFilter = ""
arguments = [accountId]
}
let sql = """ let sql = """
SELECT SELECT
t.id, t.accountId, t.subject, t.lastDate, t.messageCount, t.id, t.accountId, t.subject, t.lastDate, t.messageCount,
@@ -53,10 +65,10 @@ extension MailStore {
(SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet, (SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet,
(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount (SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount
FROM thread t FROM thread t
WHERE t.accountId = ? WHERE t.accountId = ? \(mailboxFilter)
ORDER BY t.lastDate DESC ORDER BY t.lastDate DESC
""" """
let rows = try Row.fetchAll(db, sql: sql, arguments: [accountId]) let rows = try Row.fetchAll(db, sql: sql, arguments: arguments)
return rows.map { row in return rows.map { row in
ThreadSummary( ThreadSummary(
id: row["id"], id: row["id"],