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..9d35ec3 100644 --- a/whitenoise-mac/Models/MessengerModels.swift +++ b/whitenoise-mac/Models/MessengerModels.swift @@ -121,9 +121,6 @@ nonisolated struct ChatItem: Identifiable, Hashable { let isDirect: Bool let pendingConfirmation: Bool let selfMembership: ChatSelfMembership - /// Precomputed once from `updatedAt` (which is immutable for a given value) to - /// avoid re-formatting the date on every chat-row render. - let timestampLabel: String /// Whether this chat has at least one unread @-mention of the active account. var hasMention: Bool { unreadMentionCount > 0 } @@ -163,11 +160,6 @@ nonisolated struct ChatItem: Identifiable, Hashable { self.isDirect = isDirect self.pendingConfirmation = pendingConfirmation self.selfMembership = selfMembership - if let updatedAt { - self.timestampLabel = DisplayText.relativeTimestamp(for: updatedAt) - } else { - self.timestampLabel = "" - } } } @@ -1564,13 +1556,35 @@ nonisolated struct MessageItem: Identifiable, Hashable { let nonvisualMediaAttachments: [MessageMediaAttachment] let hasBubbleContent: Bool let presentation: MessagePresentation - let timeLabel: String let statusLabel: String? - let metadataLabel: String /// 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. @@ -1642,8 +1656,6 @@ nonisolated struct MessageItem: Identifiable, Hashable { self.nonvisualMediaAttachments = partitionedAttachments.nonvisual self.hasBubbleContent = replyContext != nil || !trimmedBody.isEmpty self.presentation = presentation - let timeLabel = DisplayText.messageTimestamp(for: sentAt) - self.timeLabel = timeLabel let statusLabel: String? if presentation.isChatBubble { if invalidationStatus != nil { @@ -1655,14 +1667,6 @@ nonisolated struct MessageItem: Identifiable, Hashable { statusLabel = nil } self.statusLabel = statusLabel - var metadataParts = [timeLabel] - if isEdited { - metadataParts.append(L10n.string("Edited")) - } - if let statusLabel { - metadataParts.append(statusLabel) - } - self.metadataLabel = metadataParts.joined(separator: " ") } nonisolated private static func partitionMediaAttachments(_ attachments: [MessageMediaAttachment]) -> ( @@ -1755,8 +1759,7 @@ extension MessageItem { // while avoiding an O(AST) traversal on every comparison. That traversal otherwise // ran for each row whenever SwiftUI diffed the transcript (and on any Set/Dictionary // use), adding up across a live-update burst. The remaining fields are the - // independent inputs; rendered labels are compared too because they are cheap and - // directly displayed by the row. The rest of the stored properties (`trimmedBody`, the + // independent inputs. The rest of the stored properties (`trimmedBody`, the // media partitions, `hasBubbleContent`) are pure functions of these, so comparing them // too would be redundant. nonisolated static func == (lhs: MessageItem, rhs: MessageItem) -> Bool { @@ -1779,9 +1782,7 @@ extension MessageItem { && lhs.replyContext == rhs.replyContext && lhs.mediaAttachments == rhs.mediaAttachments && lhs.presentation == rhs.presentation - && lhs.timeLabel == rhs.timeLabel && lhs.statusLabel == rhs.statusLabel - && lhs.metadataLabel == rhs.metadataLabel } // Hashes a cheap, well-distributed subset of the equality fields. Hashing only a @@ -2289,7 +2290,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 +2302,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..f935307 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 + let timestampReferenceDate: Date + let timestampLocale: Locale 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..0f5c449 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( + updatedAt: chat.updatedAt, + referenceDate: timestampReferenceDate, + locale: locale + ) + .equatable() + .font(.caption) + .foregroundStyle(.secondary) } Text(chat.preview) .font(.caption) @@ -506,6 +513,22 @@ 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 updatedAt: Date? + let referenceDate: Date + let locale: Locale + + var body: some View { + Text( + updatedAt.map { + DisplayText.relativeTimestamp(for: $0, now: 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 128c138..36886be 100644 --- a/whitenoise-macTests/whitenoise_macTests.swift +++ b/whitenoise-macTests/whitenoise_macTests.swift @@ -191,13 +191,17 @@ private final class AtomicMax: @unchecked Sendable { private struct TranscriptPerformanceRows: View { let messages: [MessageItem] + private let timestampReferenceDate = Date() + private let timestampLocale = AppLanguage.currentLocale var body: some View { VStack(spacing: 12) { ForEach(messages) { message in ConversationMessageRow( message: message, - showsDebugMetadata: false + showsDebugMetadata: false, + timestampReferenceDate: timestampReferenceDate, + timestampLocale: timestampLocale ) { _ in } .equatable() } @@ -2996,6 +3000,57 @@ struct whitenoise_macTests { #expect(DisplayText.messageTimestamp(for: messageDate) == expected) } + @MainActor + @Test func timestampLabelsRefreshForReferenceDay() async throws { + let calendar = Calendar.autoupdatingCurrent + let weekStart = try #require(calendar.dateInterval(of: .weekOfYear, for: Date())?.start) + let dayStart = try #require(calendar.date(byAdding: .day, value: 1, to: weekStart)) + 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) + ) + let expectedWeekday = sentAt.formatted( + Date.FormatStyle.dateTime.weekday(.abbreviated).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 + ) + + let chatTimestamp = try #require(chat.updatedAt) + #expect(DisplayText.relativeTimestamp(for: chatTimestamp, now: sameDayNow, locale: locale) == expectedTime) + #expect(DisplayText.relativeTimestamp(for: chatTimestamp, now: nextDayNow, locale: locale) == expectedWeekday) + #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) @@ -3251,7 +3306,7 @@ struct whitenoise_macTests { isOutgoing: false ) - #expect(!outgoing.timeLabel.isEmpty) + #expect(!outgoing.timeLabel(at: outgoing.sentAt).isEmpty) #expect(outgoing.statusLabel == "Sent") #expect(incoming.statusLabel == nil) } @@ -3406,7 +3461,7 @@ struct whitenoise_macTests { #expect(message.timelineKind == 9) #expect(message.body == "Edited twice") #expect(message.isEdited) - #expect(message.metadataLabel.contains("Edited")) + #expect(message.metadataLabel(at: message.sentAt).contains("Edited")) #expect(message.contentMarkdown == nil) } @@ -3442,7 +3497,7 @@ struct whitenoise_macTests { #expect(message.id == "target") #expect(message.body == "Original") #expect(!message.isEdited) - #expect(!message.metadataLabel.contains("Edited")) + #expect(!message.metadataLabel(at: message.sentAt).contains("Edited")) } @MainActor