Skip to content
Merged
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
53 changes: 27 additions & 26 deletions whitenoise-mac/Models/MessengerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 = ""
}
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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]) -> (
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
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
let timestampReferenceDate: Date
let timestampLocale: Locale
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
29 changes: 26 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(
updatedAt: chat.updatedAt,
referenceDate: timestampReferenceDate,
locale: locale
)
.equatable()
.font(.caption)
.foregroundStyle(.secondary)
}
Text(chat.preview)
.font(.caption)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading