diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 5d6deb6e..aeacd51d 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ T001 /* AudioRingBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT01 /* AudioRingBufferTests.swift */; }; T002 /* AudioManagerConfigChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT02 /* AudioManagerConfigChangeTests.swift */; }; T004 /* CallManagerCallKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT04 /* CallManagerCallKitTests.swift */; }; + T005 /* MessageFormattedTimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT05 /* MessageFormattedTimeTests.swift */; }; 001 /* ColumbaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* ColumbaApp.swift */; }; 002 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* Theme.swift */; }; 003 /* ChatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* ChatsViewModel.swift */; }; @@ -139,6 +140,7 @@ FT02 /* AudioManagerConfigChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManagerConfigChangeTests.swift; sourceTree = ""; }; FT04 /* CallManagerCallKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerCallKitTests.swift; sourceTree = ""; }; FT03 /* MicronParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParserTests.swift; sourceTree = ""; }; + FT05 /* MessageFormattedTimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageFormattedTimeTests.swift; sourceTree = ""; }; TPROD /* ColumbaAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ColumbaAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EPROD /* ColumbaNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ColumbaNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F001 /* ColumbaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumbaApp.swift; sourceTree = ""; }; @@ -555,6 +557,7 @@ FT02 /* AudioManagerConfigChangeTests.swift */, FT03 /* MicronParserTests.swift */, FT04 /* CallManagerCallKitTests.swift */, + FT05 /* MessageFormattedTimeTests.swift */, ); path = Tests/ColumbaAppTests; sourceTree = ""; @@ -722,6 +725,7 @@ T002 /* AudioManagerConfigChangeTests.swift in Sources */, T003 /* MicronParserTests.swift in Sources */, T004 /* CallManagerCallKitTests.swift in Sources */, + T005 /* MessageFormattedTimeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift index f15c331f..8f422fb7 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift @@ -287,7 +287,12 @@ public struct Message: Identifiable, Equatable { /// Formatted time string (e.g., "5 min ago", "Just now") public var formattedTime: String { - Self.relativeFormatter.localizedString(for: timestamp, relativeTo: Date()) + // Clamp future-dated timestamps to now: a peer (or our own past clock) + // can stamp a wire ts ahead of local time, and we must never render + // "in 5 min" on a message that has already arrived. + let now = Date() + let display = min(timestamp, now) + return Self.relativeFormatter.localizedString(for: display, relativeTo: now) } /// True if message has no visible content (telemetry-only messages). diff --git a/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift b/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift new file mode 100644 index 00000000..cc274f4c --- /dev/null +++ b/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import ColumbaApp + +final class MessageFormattedTimeTests: XCTestCase { + + func test_formattedTime_clampsFutureTimestampToNow() { + let future = Date().addingTimeInterval(600) + let message = Message(content: "hi", timestamp: future, isFromMe: true) + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let referenceNow = Date() + let expected = formatter.localizedString(for: referenceNow, relativeTo: referenceNow) + + XCTAssertEqual(message.formattedTime, expected) + } + + func test_formattedTime_pastTimestampsRenderRelativePast() { + let oneHourAgo = Date().addingTimeInterval(-3600) + let message = Message(content: "hi", timestamp: oneHourAgo, isFromMe: false) + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let expected = formatter.localizedString(for: oneHourAgo, relativeTo: Date()) + + XCTAssertEqual(message.formattedTime, expected) + } +}