Commit d5ae78786b

d5ae78786b14802a7ca3a7d0b9eb31d5de25cef3

parent: ede847ef53

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 00:31 UTC

fix: give inbox threads identity distinct from their grouping key

InboxThreadSummary.id returned threadGroupingKey, which is listRID plus the
subject with Re:/Fwd: stripped. Two unrelated threads on one list sharing a
subject therefore shared an id — common on sourcehut, where "[PATCH] test" is
an ordinary subject — which collides under Identifiable in every list that
renders these summaries.

Key id on the root Message-ID, which is unique per thread, and leave
threadGroupingKey subject-based so replies still collapse into one
conversation.

Read state moves to threadGroupingKey at each call site. It was already
keyed on that string via id, so persisted keys are unchanged and marking a
conversation read still covers the whole subject group, matching how
HomeViewModel already builds the key for isUnread.

Layout: unified · split

Hutch/App/RootView.swift +3 −3
@@ -476,15 +476,15 @@ private struct MoreNavigationRoot: View {
476476 ThreadDetailView(
477477 thread: thread,
478478 onViewed: {
479 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
479 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
480480 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
481481 },
482482 onMarkRead: {
483 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
483 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
484484 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485485 },
486486 onMarkUnread: {
487 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
487 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488488 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
489489 }
490490 )
Hutch/Models/Inbox.swift +4 −1
@@ -17,8 +17,11 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
1717 let containsPatch: Bool
1818 let isUnread: Bool
1919
20 /// Identity is per-thread, keyed on the root Message-ID. It deliberately differs
21 /// from ``threadGroupingKey``, which is subject-based so replies collapse into
22 /// one conversation — two unrelated threads can share a subject on the same list.
2023 var id: String {
21 threadGroupingKey
24 "\(listRID)#\(rootMessageID)"
2225 }
2326
2427 var listDisplayName: String {
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -622,7 +622,7 @@ final class HomeViewModel {
622622 }
623623
624624 func markInboxThreadRead(_ thread: InboxThreadSummary) {
625 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
625 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
626626 unreadInboxThreads.removeAll { $0.id == thread.id }
627627 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
628628 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
@@ -634,7 +634,7 @@ final class HomeViewModel {
634634
635635 let viewedAt = Date()
636636 for thread in unreadInboxThreads {
637 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
637 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
638638 }
639639
640640 unreadInboxThreads = []
@@ -644,7 +644,7 @@ final class HomeViewModel {
644644 }
645645
646646 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
647 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
647 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
648648 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
649649 unreadInboxThreads.append(
650650 InboxThreadSummary(
Hutch/Views/Lookup/LookupView.swift +3 −3
@@ -469,15 +469,15 @@ struct LookupView: View {
469469 ThreadDetailView(
470470 thread: thread,
471471 onViewed: {
472 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
472 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
473473 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
474474 },
475475 onMarkRead: {
476 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
476 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: appState.accountDefaults)
477477 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
478478 },
479479 onMarkUnread: {
480 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
480 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
481481 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
482482 }
483483 )
Hutch/Views/Projects/ProjectMailingListView.swift +3 −3
@@ -94,13 +94,13 @@ final class MailingListDetailViewModel {
9494
9595 func markThreadRead(_ thread: InboxThreadSummary) {
9696 let viewedAt = max(Date(), thread.lastActivityAt)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
9898 updateThread(thread, isUnread: false)
9999 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
100100 }
101101
102102 func markThreadUnread(_ thread: InboxThreadSummary) {
103 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
103 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
104104 updateThread(thread, isUnread: true)
105105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
106106 }
@@ -111,7 +111,7 @@ final class MailingListDetailViewModel {
111111
112112 let viewedAt = Date()
113113 for thread in unreadThreads {
114 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
114 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
115115 }
116116
117117 threads = threads.map { thread in