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 {
476 ThreadDetailView( 476 ThreadDetailView(
477 thread: thread, 477 thread: thread,
478 onViewed: { 478 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)
480 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) 480 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
481 }, 481 },
482 onMarkRead: { 482 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)
484 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) 484 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
485 }, 485 },
486 onMarkUnread: { 486 onMarkUnread: {
487 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) 487 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
488 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) 488 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
489 } 489 }
490 ) 490 )
Hutch/Models/Inbox.swift +4 −1
@@ -17,8 +17,11 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
17 let containsPatch: Bool 17 let containsPatch: Bool
18 let isUnread: Bool 18 let isUnread: Bool
19 19
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.
20 var id: String { 23 var id: String {
21 threadGroupingKey 24 "\(listRID)#\(rootMessageID)"
22 } 25 }
23 26
24 var listDisplayName: String { 27 var listDisplayName: String {
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -622,7 +622,7 @@ final class HomeViewModel {
622 } 622 }
623 623
624 func markInboxThreadRead(_ thread: InboxThreadSummary) { 624 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)
626 unreadInboxThreads.removeAll { $0.id == thread.id } 626 unreadInboxThreads.removeAll { $0.id == thread.id }
627 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) 627 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
628 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 628 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
@@ -634,7 +634,7 @@ final class HomeViewModel {
634 634
635 let viewedAt = Date() 635 let viewedAt = Date()
636 for thread in unreadInboxThreads { 636 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)
638 } 638 }
639 639
640 unreadInboxThreads = [] 640 unreadInboxThreads = []
@@ -644,7 +644,7 @@ final class HomeViewModel {
644 } 644 }
645 645
646 func markInboxThreadUnread(_ thread: InboxThreadSummary) { 646 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
647 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) 647 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
648 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { 648 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
649 unreadInboxThreads.append( 649 unreadInboxThreads.append(
650 InboxThreadSummary( 650 InboxThreadSummary(
Hutch/Views/Lookup/LookupView.swift +3 −3
@@ -469,15 +469,15 @@ struct LookupView: View {
469 ThreadDetailView( 469 ThreadDetailView(
470 thread: thread, 470 thread: thread,
471 onViewed: { 471 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)
473 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) 473 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
474 }, 474 },
475 onMarkRead: { 475 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)
477 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID) 477 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
478 }, 478 },
479 onMarkUnread: { 479 onMarkUnread: {
480 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults) 480 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: appState.accountDefaults)
481 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID) 481 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
482 } 482 }
483 ) 483 )
Hutch/Views/Projects/ProjectMailingListView.swift +3 −3
@@ -94,13 +94,13 @@ final class MailingListDetailViewModel {
94 94
95 func markThreadRead(_ thread: InboxThreadSummary) { 95 func markThreadRead(_ thread: InboxThreadSummary) {
96 let viewedAt = max(Date(), thread.lastActivityAt) 96 let viewedAt = max(Date(), thread.lastActivityAt)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults) 97 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
98 updateThread(thread, isUnread: false) 98 updateThread(thread, isUnread: false)
99 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID) 99 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
100 } 100 }
101 101
102 func markThreadUnread(_ thread: InboxThreadSummary) { 102 func markThreadUnread(_ thread: InboxThreadSummary) {
103 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) 103 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
104 updateThread(thread, isUnread: true) 104 updateThread(thread, isUnread: true)
105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID) 105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
106 } 106 }
@@ -111,7 +111,7 @@ final class MailingListDetailViewModel {
111 111
112 let viewedAt = Date() 112 let viewedAt = Date()
113 for thread in unreadThreads { 113 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)
115 } 115 }
116 116
117 threads = threads.map { thread in 117 threads = threads.map { thread in