From 4c51ae36d91cf46b58eef7210f040573242007e9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 22:00:10 -0400 Subject: [PATCH] feat(chat): open date separators after a one-hour pause The transcript's separator gap was three hours, so a morning and an afternoon exchange on the same day read as one run. Drop it to one hour, matching Android's SeparatorConfig.Continuous. ChatPreviewMapping, which builds the notification preview, used 15 minutes while its doc comment said it matched the in-app chat. It now uses the same one hour. --- .../Conversation/ChatItem+Conversation.swift | 6 +++--- .../Models/Chat/ChatPreviewMapping.swift | 2 +- .../Chat/ChatMessageMappingTests.swift | 14 ++++++++++++++ FlipcashTests/ChatPreviewMappingTests.swift | 19 +++++++++++++++---- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 7c28c65c9..8c35a9a74 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -81,9 +81,9 @@ extension ChatItem { _ messages: [ConversationMessage], selfUserID: UserID, // How long a pause has to be before it heads the next message with its own separator and - // ends the run above it. Android's `SeparatorConfig.Continuous` gap, so a pause of minutes - // keeps one exchange together on both platforms instead of cutting it into single bubbles. - gap: TimeInterval = 3 * 60 * 60, + // ends the run above it. Android's `SeparatorConfig.Continuous` gap (one hour), so a pause of + // minutes keeps one exchange together on both platforms instead of cutting it into single bubbles. + gap: TimeInterval = 60 * 60, counterpartRead: (pointer: MessageID, date: Date?)? = nil, suppressReceiptFor: String? = nil, cashBranding: (ExchangedFiat) -> (token: String, iconURL: URL?) = { _ in ("Cash", nil) }, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index c73ab3c89..82a1d6113 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -156,5 +156,5 @@ extension ChatItem { /// Matches the in-app chat: a separator opens the transcript and breaks any run whose gap from /// the previous message exceeds this. - private static let separatorGap: TimeInterval = 15 * 60 + private static let separatorGap: TimeInterval = 60 * 60 } diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index d63b1f716..62dac3f0a 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -322,6 +322,20 @@ struct ChatMessageMappingTests { #expect(!broken[1].isContinuationFromPrevious) } + @Test("A 90-minute pause opens a date separator; a 30-minute pause does not") + func hourGapOpensSeparator() { + func separators(afterPause pause: TimeInterval) -> Int { + let rows = [ + ConversationMessage(id: MessageID(value: 1), senderID: me, content: .text("a"), date: base, unreadSeq: 1), + ConversationMessage(id: MessageID(value: 2), senderID: me, content: .text("b"), date: base.addingTimeInterval(pause), unreadSeq: 2), + ] + return separatorCount(ChatItem.from(rows, selfUserID: me)) + } + + #expect(separators(afterPause: 30 * 60) == 1) // only the one opening the transcript + #expect(separators(afterPause: 90 * 60) == 2) // the pause heads the second message + } + @Test("A run does not carry across a change of day, however short the pause") func dayChangeBreaksRun() { // Straddling local midnight, so the two rows are minutes apart but on different dates. diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift index 9e7241c13..d39be8be3 100644 --- a/FlipcashTests/ChatPreviewMappingTests.swift +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -244,7 +244,7 @@ struct ChatPreviewMappingTests { @Test("Messages within the gap share a single opening separator") func messagesWithinGapShareSeparator() { - // ids 1 and 2 → dates 1s apart, well under the 15-minute gap. + // ids 1 and 2 → dates 1s apart, well under the one-hour gap. let messages = [ textMessage(id: 1, senderID: meID, text: "a"), textMessage(id: 2, senderID: meID, text: "b"), @@ -253,17 +253,28 @@ struct ChatPreviewMappingTests { #expect(separators(items).count == 1) } - @Test("A gap longer than 15 minutes inserts another separator") + @Test("A gap longer than one hour inserts another separator") func gapInsertsSeparator() { - // ids 1 and 1000 → dates 999s (~16.6 min) apart, beyond the 15-minute gap. + // ids 1 and 5401 → dates 5400s (90 min) apart, beyond the one-hour gap. let messages = [ textMessage(id: 1, senderID: meID, text: "a"), - textMessage(id: 1000, senderID: meID, text: "b"), + textMessage(id: 5401, senderID: meID, text: "b"), ] let items = ChatItem.preview(from: messages, selfUserID: meID) #expect(separators(items).count == 2) } + @Test("A pause of 30 minutes does not insert another separator") + func shortPauseKeepsOneSeparator() { + // ids 1 and 1801 → dates 1800s (30 min) apart, under the one-hour gap. + let messages = [ + textMessage(id: 1, senderID: meID, text: "a"), + textMessage(id: 1801, senderID: meID, text: "b"), + ] + let items = ChatItem.preview(from: messages, selfUserID: meID) + #expect(separators(items).count == 1) + } + // MARK: - Cache serialization @Test("Preview round-trips through Codable unchanged — the notification cache's mechanism")