diff --git a/whitenoise-mac/ContentView.swift b/whitenoise-mac/ContentView.swift index 528d0b3..7e8d697 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 e5df2e0..dd21c56 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 60308b2..7cf9906 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 93db848..ff01bc6 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 c5f6816..11f5042 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,9 +466,14 @@ struct ChatRowContent: View { PendingInviteBadge() } Spacer(minLength: 8) - Text(chat.timestampLabel) - .font(.caption) - .foregroundStyle(.secondary) + ChatTimestampText( + chat: chat, + referenceDate: timestampReferenceDate, + locale: locale + ) + .equatable() + .font(.caption) + .foregroundStyle(.secondary) } Text(chat.preview) .font(.caption) @@ -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 eee18d6..0536ba5 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)