diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 151bcb4..293c7df 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -133,6 +133,7 @@ 8A321B0938566F0D62D64562 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */; }; 922658C82CEEA53695143F9B /* MapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = DBD8F3A253D413F087742BC0 /* MapLibre */; }; 92DDF4AE0AB493CC2AA0BA20 /* LXSTSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 6000D5CED2C3C89F74999BC1 /* LXSTSwift */; }; + 9566DCEF56FEFC97BAAA47BA /* MessageFormattedTimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */; }; 98547ADE9B17DD692240E7F7 /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 85B9530D8CAE0E16D5371319 /* LXMFSwift */; }; 9D9069A3F6302111A4727454 /* SwiftBLEBridge in Frameworks */ = {isa = PBXBuildFile; productRef = DD88CFE74E7E22427BC4D163 /* SwiftBLEBridge */; }; A0C0D9AE12F728A484F0F108 /* AudioManagerConfigChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */; }; @@ -207,6 +208,7 @@ AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = ""; }; B1AB71D8977FE792BA9B176D /* JetBrainsMono-Bold.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = "JetBrainsMono-Bold.ttf"; sourceTree = ""; }; B68384C48BFF8F5294340EDB /* PttButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PttButton.swift; sourceTree = ""; }; + BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageFormattedTimeTests.swift; sourceTree = ""; }; BF48C97880B30682DC35613C /* CeaseTelemetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CeaseTelemetry.swift; sourceTree = ""; }; BKF002 /* BackendFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendFactory.swift; sourceTree = ""; }; CCF4DCA18506B96230721ACC /* PythonBLECallbackBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonBLECallbackBridge.swift; sourceTree = ""; }; @@ -682,6 +684,7 @@ DE307E24C72DBA41D76C9A6C /* AudioRingBufferTests.swift */, 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */, 30A79B9ECDB4166F8A36A670 /* CallManagerCallKitTests.swift */, + BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */, ); path = Tests/ColumbaAppTests; sourceTree = ""; @@ -1082,6 +1085,7 @@ 2B3A3E3FB59D1E22B424E109 /* AudioRingBufferTests.swift in Sources */, A0C0D9AE12F728A484F0F108 /* AudioManagerConfigChangeTests.swift in Sources */, E49B977D311DA1C9ACE14CAB /* CallManagerCallKitTests.swift in Sources */, + 9566DCEF56FEFC97BAAA47BA /* MessageFormattedTimeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift index 88f6d5b..b27a0d0 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift @@ -305,7 +305,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 0000000..169bba1 --- /dev/null +++ b/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift @@ -0,0 +1,33 @@ +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() { + // 2 hours is comfortably mid-bucket for the abbreviated formatter. The + // 60-min mark is a min/hr rounding boundary, so a 1-hr offset could let + // the two independent Date() reads (here vs. inside formattedTime) + // straddle it and flake. Anchor `now` once for the expected value. + let now = Date() + let twoHoursAgo = now.addingTimeInterval(-7200) + let message = Message(content: "hi", timestamp: twoHoursAgo, isFromMe: false) + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let expected = formatter.localizedString(for: twoHoursAgo, relativeTo: now) + + XCTAssertEqual(message.formattedTime, expected) + } +}