From a47248772c29e1ef53f8eb808abf5ec38734f49d Mon Sep 17 00:00:00 2001 From: agent-p1p Date: Sun, 12 Jul 2026 05:40:34 +0200 Subject: [PATCH 1/2] fix: refresh relative timestamps after midnight Refresh chat and message timestamp projections when the calendar day changes or the app returns to the foreground. Keep formatting outside ordinary row renders and cover the day-boundary transition with a regression test. Fixes #470 --- whitenoise-mac/ContentView.swift | 24 ++++++++++ whitenoise-mac/Models/MessengerModels.swift | 39 +++++++++++++++- whitenoise-mac/Views/MessageMediaViews.swift | 21 +++++++-- whitenoise-mac/Views/MessengerShellView.swift | 6 ++- whitenoise-mac/Views/SidebarViews.swift | 21 ++++++++- whitenoise-macTests/whitenoise_macTests.swift | 46 +++++++++++++++++++ 6 files changed, 150 insertions(+), 7 deletions(-) diff --git a/whitenoise-mac/ContentView.swift b/whitenoise-mac/ContentView.swift index 528d0b3c..7e8d697d 100644 --- a/whitenoise-mac/ContentView.swift +++ b/whitenoise-mac/ContentView.swift @@ -8,14 +8,27 @@ import AppKit import SwiftUI +private struct TimestampReferenceDateKey: EnvironmentKey { + static let defaultValue = Date() +} + +extension EnvironmentValues { + var timestampReferenceDate: Date { + get { self[TimestampReferenceDateKey.self] } + set { self[TimestampReferenceDateKey.self] = newValue } + } +} + struct ContentView: View { @Environment(WorkspaceState.self) private var workspace + @State private var timestampReferenceDate = Date() var body: some View { MessengerShellView() .frame(minWidth: 940, minHeight: 620) .preferredColorScheme(workspace.preferredColorScheme) .environment(\.locale, workspace.preferredLocale) + .environment(\.timestampReferenceDate, timestampReferenceDate) .environment( \.openURL, OpenURLAction { url in @@ -40,11 +53,17 @@ struct ContentView: View { ) { _ in workspace.refreshSystemLanguageIfNeeded() } + .onReceive( + NotificationCenter.default.publisher(for: .NSCalendarDayChanged) + ) { _ in + timestampReferenceDate = Date() + } .onReceive( NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification ) ) { _ in + refreshTimestampReferenceDateIfNeeded() workspace.refreshSystemLanguageIfNeeded() // The app just regained focus. Flush any read-marking that was deferred // while it was in the background so the selected chat clears its unread @@ -70,6 +89,11 @@ struct ContentView: View { private func applyAppearance(_ preference: AppearancePreference) { NativeAppearanceController.apply(preference) } + + private func refreshTimestampReferenceDateIfNeeded(now: Date = Date()) { + guard !Calendar.autoupdatingCurrent.isDate(timestampReferenceDate, inSameDayAs: now) else { return } + timestampReferenceDate = now + } } #Preview { diff --git a/whitenoise-mac/Models/MessengerModels.swift b/whitenoise-mac/Models/MessengerModels.swift index e5df2e00..dd21c569 100644 --- a/whitenoise-mac/Models/MessengerModels.swift +++ b/whitenoise-mac/Models/MessengerModels.swift @@ -134,6 +134,17 @@ nonisolated struct ChatItem: Identifiable, Hashable { /// True when the local account can use the outbound composer for this chat. var canUseComposer: Bool { !pendingConfirmation && !isNoLongerMember } + /// Re-derives the relative spelling against a wall-clock reference date. Views use + /// this when the calendar day changes; `timestampLabel` remains the cheap mapping-time + /// value used during ordinary renders. + nonisolated func timestampLabel( + at now: Date, + locale: Locale = AppLanguage.currentLocale + ) -> String { + guard let updatedAt else { return "" } + return DisplayText.relativeTimestamp(for: updatedAt, now: now, locale: locale) + } + init( id: String, title: String, @@ -1571,6 +1582,30 @@ nonisolated struct MessageItem: Identifiable, Hashable { /// Whether the bubble should render the parsed Markdown AST instead of plain text. var rendersMarkdown: Bool { contentMarkdown != nil } + /// Re-derives the date-sensitive portion of the label after a calendar-day change. + nonisolated func timeLabel( + at now: Date, + locale: Locale = AppLanguage.currentLocale + ) -> String { + DisplayText.messageTimestamp(for: sentAt, now: now, locale: locale) + } + + /// Rebuilds the rendered metadata while preserving the message's static edited and + /// delivery-state suffixes. + nonisolated func metadataLabel( + at now: Date, + locale: Locale = AppLanguage.currentLocale + ) -> String { + var parts = [timeLabel(at: now, locale: locale)] + if isEdited { + parts.append(L10n.string("Edited")) + } + if let statusLabel { + parts.append(statusLabel) + } + return parts.joined(separator: " ") + } + // `nonisolated` so the timeline record → view-model mapping (`MessageItem.timeline`) // can build items in the off-main window/projection closure (whitenoise-mac#285) // without inheriting the module's default main-actor isolation. @@ -2289,7 +2324,7 @@ nonisolated enum DisplayText { static func relativeTimestamp(for date: Date, now: Date = Date(), locale: Locale = AppLanguage.currentLocale) -> String { - if calendar.isDateInToday(date) { + if calendar.isDate(date, inSameDayAs: now) { return date.formatted(timeOnlyStyle.locale(locale)) } if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { @@ -2301,7 +2336,7 @@ nonisolated enum DisplayText { static func messageTimestamp(for date: Date, now: Date = Date(), locale: Locale = AppLanguage.currentLocale) -> String { - if calendar.isDateInToday(date) { + if calendar.isDate(date, inSameDayAs: now) { return date.formatted(timeOnlyStyle.locale(locale)) } return dateTimeTimestamp(for: date, locale: locale) diff --git a/whitenoise-mac/Views/MessageMediaViews.swift b/whitenoise-mac/Views/MessageMediaViews.swift index 60308b26..7cf99061 100644 --- a/whitenoise-mac/Views/MessageMediaViews.swift +++ b/whitenoise-mac/Views/MessageMediaViews.swift @@ -75,6 +75,8 @@ struct ConversationMessageRow: View, Equatable { /// Keep the debug toggle as a row input so `.equatable()` still updates rows when the /// diagnostics presentation changes, while hover/selection churn is scoped below the row. var showsDebugMetadata = false + var timestampReferenceDate = Date() + var timestampLocale = AppLanguage.currentLocale let onOpenImageGallery: (MessageImageGalleryPresentation) -> Void // Receives the resolved MessageItem by value (not via a shared @Observable lookup), @@ -85,16 +87,25 @@ struct ConversationMessageRow: View, Equatable { MessageBubble( message: message, showsDebugMetadata: showsDebugMetadata, + timestampReferenceDate: timestampReferenceDate, + timestampLocale: timestampLocale, onOpenImageGallery: onOpenImageGallery ) } else { - TimelineNoticeRow(message: message, showsDebugMetadata: showsDebugMetadata) + TimelineNoticeRow( + message: message, + showsDebugMetadata: showsDebugMetadata, + timestampReferenceDate: timestampReferenceDate, + timestampLocale: timestampLocale + ) } } static func == (lhs: ConversationMessageRow, rhs: ConversationMessageRow) -> Bool { lhs.message == rhs.message && lhs.showsDebugMetadata == rhs.showsDebugMetadata + && lhs.timestampReferenceDate == rhs.timestampReferenceDate + && lhs.timestampLocale == rhs.timestampLocale } } @@ -102,6 +113,8 @@ struct TimelineNoticeRow: View { @Environment(WorkspaceState.self) private var workspace let message: MessageItem let showsDebugMetadata: Bool + let timestampReferenceDate: Date + let timestampLocale: Locale var body: some View { HStack { @@ -118,7 +131,7 @@ struct TimelineNoticeRow: View { .lineLimit(3) .multilineTextAlignment(.center) - Text(message.timeLabel) + Text(message.timeLabel(at: timestampReferenceDate, locale: timestampLocale)) .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) } @@ -155,6 +168,8 @@ struct MessageBubble: View { @State private var isSelectable = false let message: MessageItem let showsDebugMetadata: Bool + let timestampReferenceDate: Date + let timestampLocale: Locale let onOpenImageGallery: (MessageImageGalleryPresentation) -> Void var body: some View { @@ -195,7 +210,7 @@ struct MessageBubble: View { bubbleContent } - Text(message.metadataLabel) + Text(message.metadataLabel(at: timestampReferenceDate, locale: timestampLocale)) .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) .padding(.horizontal, 5) diff --git a/whitenoise-mac/Views/MessengerShellView.swift b/whitenoise-mac/Views/MessengerShellView.swift index 93db8488..ff01bc6a 100644 --- a/whitenoise-mac/Views/MessengerShellView.swift +++ b/whitenoise-mac/Views/MessengerShellView.swift @@ -276,6 +276,8 @@ func timelineNewestMessageScrollAction( private struct ConversationView: View { @Environment(WorkspaceState.self) private var workspace + @Environment(\.locale) private var locale + @Environment(\.timestampReferenceDate) private var timestampReferenceDate /// The top message captured before an older-history prepend, so its on-screen position /// can be restored afterward; also gates re-triggering `loadOlder` until the prepend lands. @State private var pendingPrependAnchorId: String? @@ -338,7 +340,9 @@ private struct ConversationView: View { ForEach(messages) { message in ConversationMessageRow( message: message, - showsDebugMetadata: workspace.streamingDebugEnabled + showsDebugMetadata: workspace.streamingDebugEnabled, + timestampReferenceDate: timestampReferenceDate, + timestampLocale: locale ) { gallery in imageGallery = gallery } diff --git a/whitenoise-mac/Views/SidebarViews.swift b/whitenoise-mac/Views/SidebarViews.swift index c5f68160..af035f06 100644 --- a/whitenoise-mac/Views/SidebarViews.swift +++ b/whitenoise-mac/Views/SidebarViews.swift @@ -437,6 +437,8 @@ struct SettingsSidebarRow: View { } struct ChatRowContent: View { + @Environment(\.locale) private var locale + @Environment(\.timestampReferenceDate) private var timestampReferenceDate let chat: ChatItem let isSelected: Bool @@ -464,7 +466,12 @@ struct ChatRowContent: View { PendingInviteBadge() } Spacer(minLength: 8) - Text(chat.timestampLabel) + ChatTimestampText( + chat: chat, + referenceDate: timestampReferenceDate, + locale: locale + ) + .equatable() .font(.caption) .foregroundStyle(.secondary) } @@ -506,6 +513,18 @@ struct ChatRowContent: View { } } +/// Keeps date formatting out of ordinary chat-row render passes while still allowing +/// the label to change when the app's shared calendar-day reference advances. +private struct ChatTimestampText: View, Equatable { + let chat: ChatItem + let referenceDate: Date + let locale: Locale + + var body: some View { + Text(chat.timestampLabel(at: referenceDate, locale: locale)) + } +} + struct PendingInviteBadge: View { var body: some View { Label("Invite", systemImage: "envelope.badge") diff --git a/whitenoise-macTests/whitenoise_macTests.swift b/whitenoise-macTests/whitenoise_macTests.swift index eee18d6a..0536ba57 100644 --- a/whitenoise-macTests/whitenoise_macTests.swift +++ b/whitenoise-macTests/whitenoise_macTests.swift @@ -3005,6 +3005,52 @@ struct whitenoise_macTests { #expect(DisplayText.messageTimestamp(for: messageDate) == expected) } + @MainActor + @Test func timestampLabelsRefreshForReferenceDay() async throws { + let calendar = Calendar.autoupdatingCurrent + let dayStart = calendar.startOfDay(for: Date()) + let sentAt = try #require(calendar.date(byAdding: .hour, value: 15, to: dayStart)) + let sameDayNow = try #require(calendar.date(byAdding: .hour, value: 16, to: dayStart)) + let nextDayNow = try #require(calendar.date(byAdding: .day, value: 1, to: sameDayNow)) + let locale = Locale(identifier: "en_US") + let expectedTime = sentAt.formatted( + Date.FormatStyle(date: .omitted, time: .shortened).locale(locale) + ) + let expectedDateTime = sentAt.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) + ) + + #expect(DisplayText.messageTimestamp(for: sentAt, now: sameDayNow, locale: locale) == expectedTime) + #expect(DisplayText.messageTimestamp(for: sentAt, now: nextDayNow, locale: locale) == expectedDateTime) + + let chat = ChatItem( + id: "day-boundary-chat", + title: "Day Boundary", + subtitle: "", + preview: "", + updatedAt: sentAt, + avatarSeed: "day-boundary-chat", + pictureURL: nil, + unreadCount: 0 + ) + let message = MessageItem( + id: "day-boundary-message", + senderName: "Alice", + body: "Still here", + sentAt: sentAt, + isEdited: true, + isOutgoing: false + ) + + #expect(chat.timestampLabel(at: sameDayNow, locale: locale) == expectedTime) + #expect(chat.timestampLabel(at: nextDayNow, locale: locale) != expectedTime) + #expect(message.timeLabel(at: nextDayNow, locale: locale) == expectedDateTime) + #expect( + message.metadataLabel(at: nextDayNow, locale: locale) + == "\(expectedDateTime) \(L10n.string("Edited"))" + ) + } + @MainActor @Test func localizedStringUsesSelectedAppLanguage() async throws { let previousLanguage = UserDefaults.standard.object(forKey: AppLanguage.storageKey) From 560f641bddc809abef9e0e0528b419e079e45cec Mon Sep 17 00:00:00 2001 From: agent-p1p Date: Sun, 12 Jul 2026 05:42:44 +0200 Subject: [PATCH 2/2] style: align timestamp view modifiers --- whitenoise-mac/Views/SidebarViews.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/whitenoise-mac/Views/SidebarViews.swift b/whitenoise-mac/Views/SidebarViews.swift index af035f06..11f5042a 100644 --- a/whitenoise-mac/Views/SidebarViews.swift +++ b/whitenoise-mac/Views/SidebarViews.swift @@ -472,8 +472,8 @@ struct ChatRowContent: View { locale: locale ) .equatable() - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption) + .foregroundStyle(.secondary) } Text(chat.preview) .font(.caption)