Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions whitenoise-mac/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
39 changes: 37 additions & 2 deletions whitenoise-mac/Models/MessengerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions whitenoise-mac/Views/MessageMediaViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -85,23 +87,34 @@ 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
}
}

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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion whitenoise-mac/Views/MessengerShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}
Expand Down
25 changes: 22 additions & 3 deletions whitenoise-mac/Views/SidebarViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
46 changes: 46 additions & 0 deletions whitenoise-macTests/whitenoise_macTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading