From ca8a1ed842380696e0dab9292050c6d1a13a2fd3 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 22:44:56 -0400 Subject: [PATCH 1/2] fix(MessageBubble): clamp future-dated timestamps to now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A future-dated wire timestamp (peer clock skew, or our own past clock) renders 'in 5 min' on a message that already arrived. Clamp the displayed relative time to min(timestamp, now) in Message.formattedTime so it shows 'Just now' instead. The absolute-time formatter in MessageDetailView is intentionally left alone — it shows raw protocol metadata. Adds MessageFormattedTimeTests (future -> now; past -> relative-past regression guard). Verified: build-for-testing + both tests pass on sim. Re-applies the iOS half of #60 fresh on current main — the original PR #66 predates the dual-backend refactor and is 78 commits stale with pbxproj + MessageBubble conflicts. Supersedes #66. Refs #60. Co-Authored-By: Claude Opus 4.8 --- Columba.xcodeproj/project.pbxproj | 4 +++ .../Views/Messaging/MessageBubble.swift | 7 ++++- .../MessageFormattedTimeTests.swift | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 Tests/ColumbaAppTests/MessageFormattedTimeTests.swift diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 151bcb43..293c7df2 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 88f6d5bc..b27a0d09 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 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) + } +} From 96ed8b7c951251d11333e18ca375277ed362d68d Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 22:53:42 -0400 Subject: [PATCH 2/2] address greptile review feedback (greploop iteration 1) Make the past-timestamp test boundary-safe: 1 hr sits on the abbreviated formatter's min/hr rounding boundary, so the two independent Date() reads (test vs inside formattedTime) could straddle it and flake. Use a 2-hr mid-bucket offset and anchor 'now' once. Verified: both tests still pass. Co-Authored-By: Claude Opus 4.8 --- Tests/ColumbaAppTests/MessageFormattedTimeTests.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift b/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift index cc274f4c..169bba10 100644 --- a/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift +++ b/Tests/ColumbaAppTests/MessageFormattedTimeTests.swift @@ -16,12 +16,17 @@ final class MessageFormattedTimeTests: XCTestCase { } func test_formattedTime_pastTimestampsRenderRelativePast() { - let oneHourAgo = Date().addingTimeInterval(-3600) - let message = Message(content: "hi", timestamp: oneHourAgo, isFromMe: false) + // 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: oneHourAgo, relativeTo: Date()) + let expected = formatter.localizedString(for: twoHoursAgo, relativeTo: now) XCTAssertEqual(message.formattedTime, expected) }