From 1e2f0aa0c5831394025580af0b5615baca7d1834 Mon Sep 17 00:00:00 2001 From: PandaDEV Date: Wed, 10 Jun 2026 02:34:58 +0200 Subject: [PATCH 1/2] refactor(chat): replace per-cluster timestamps with full-date day dividers --- .../Components/MessageDayDividerView.swift | 50 ++++--------------- .../Components/MessageTimestampView.swift | 22 -------- 2 files changed, 11 insertions(+), 61 deletions(-) delete mode 100644 MC1/Views/Chats/Components/MessageTimestampView.swift diff --git a/MC1/Views/Chats/Components/MessageDayDividerView.swift b/MC1/Views/Chats/Components/MessageDayDividerView.swift index 118fe73b6..1e9844e7f 100644 --- a/MC1/Views/Chats/Components/MessageDayDividerView.swift +++ b/MC1/Views/Chats/Components/MessageDayDividerView.swift @@ -1,46 +1,23 @@ import SwiftUI -/// Day separator shown once per calendar day in chat history, carrying the -/// relative day so the time markers (`MessageTimestampView`) stay time-only. -/// Wrapped in `TimelineView` so "Today" rolls to "Yesterday" at midnight. +/// Day separator shown once per calendar day in chat history. Rendered as a +/// centered, lined divider (matching `NewMessagesDividerView`) carrying the full +/// calendar date — e.g. "10 June 2026" — so each day's first message is clearly +/// anchored in time now that the per-cluster time marker lives inside the bubble. struct MessageDayDividerView: View { let date: Date var body: some View { - TimelineView(.everyMinute) { context in - Text(label(relativeTo: context.date)) - .font(.caption) + HStack { + VStack { Divider() } + Text(date.formatted(.dateTime.day().month(.wide).year())) + .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) - } - } - - private func label(relativeTo now: Date) -> String { - let calendar = Calendar.current - - if calendar.isDateInToday(date) { - return L10n.Chats.Chats.Timestamp.today - } else if calendar.isDateInYesterday(date) { - return L10n.Chats.Chats.Timestamp.yesterday - } - - // Weekday name for the past week, capped at 6 days so it cannot collide - // with today's weekday (which 7 days ago would share). - let dayOffset = calendar.dateComponents( - [.day], - from: calendar.startOfDay(for: date), - to: calendar.startOfDay(for: now) - ).day ?? 0 - - if (2...6).contains(dayOffset) { - return date.formatted(.dateTime.weekday(.wide)) - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - return date.formatted(.dateTime.month(.abbreviated).day()) - } else { - return date.formatted(.dateTime.month(.abbreviated).day().year()) + .fixedSize() + VStack { Divider() } } + .padding(.vertical, 4) } } @@ -54,11 +31,6 @@ struct MessageDayDividerView: View { .padding() } -#Preview("Weekday") { - MessageDayDividerView(date: Calendar.current.date(byAdding: .day, value: -3, to: Date())!) - .padding() -} - #Preview("Last Year") { MessageDayDividerView(date: Calendar.current.date(byAdding: .year, value: -1, to: Date())!) .padding() diff --git a/MC1/Views/Chats/Components/MessageTimestampView.swift b/MC1/Views/Chats/Components/MessageTimestampView.swift deleted file mode 100644 index 9419f15c4..000000000 --- a/MC1/Views/Chats/Components/MessageTimestampView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -/// Centered, time-only marker shown between message clusters when a time gap -/// breaks grouping. The day is carried separately by `MessageDayDividerView`. -struct MessageTimestampView: View { - let date: Date - - var body: some View { - Text(date.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } -} - -#Preview { - VStack(spacing: 20) { - MessageTimestampView(date: Date()) - MessageTimestampView(date: Date().addingTimeInterval(-3600)) // 1 hour ago - } - .padding() -} From bd79ee7407559d6c023a0a0faa1d7e125a59ff84 Mon Sep 17 00:00:00 2001 From: PandaDEV Date: Wed, 10 Jun 2026 02:35:07 +0200 Subject: [PATCH 2/2] feat(chat): show send time on all bubbles with adaptive color, remove toggle --- .../Chats/Components/BubbleFooterRow.swift | 14 +++++++++-- .../Components/BubbleFragmentStack.swift | 22 +++++++++++++--- .../Components/UnifiedMessageBubble.swift | 25 +++++++++++++------ .../Sections/MessagesSettingsSection.swift | 2 -- .../Services/MessageFragmentBuilder.swift | 16 ++++++------ .../Models/MessageFragmentBuilderTests.swift | 23 +++++++++-------- 6 files changed, 67 insertions(+), 35 deletions(-) diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index 41e3c8fd5..c5e9db799 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -14,6 +14,11 @@ private let correctedClockBadgeSymbol = "clock.badge.exclamationmark" struct BubbleFooterRow: View { let footer: MessageFooter let dynamicTypeSize: DynamicTypeSize + /// Color for the send-time text. Varies by bubble: `.secondary` on the gray + /// incoming bubble, a translucent outgoing-text color on the accent-colored + /// outgoing bubble where `.secondary` would wash out. Hop/path/region rows + /// stay `.secondary` because they only ever appear on incoming bubbles. + var timeColor: Color = .secondary var body: some View { if dynamicTypeSize.isAccessibilitySize { @@ -32,7 +37,11 @@ struct BubbleFooterRow: View { @ViewBuilder private func footerContents(allowsWrap: Bool) -> some View { if let sendTime = footer.sendTimeToShow { - BubbleSendTimeFooter(date: sendTime, wasCorrected: footer.sendTimeWasCorrected) + BubbleSendTimeFooter( + date: sendTime, + wasCorrected: footer.sendTimeWasCorrected, + color: timeColor + ) } if footer.showHop { BubbleHopCountFooter(hopCount: footer.hopCount) @@ -49,6 +58,7 @@ struct BubbleFooterRow: View { private struct BubbleSendTimeFooter: View { let date: Date let wasCorrected: Bool + let color: Color private var timeText: String { date.formatted(date: .omitted, time: .shortened) @@ -68,7 +78,7 @@ private struct BubbleSendTimeFooter: View { Text(timeText) } .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(color) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel) } diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 6aea41373..07446f606 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -16,13 +16,17 @@ private let bubbleCornerRadius: CGFloat = 16 struct BubbleFragmentStack: View, Equatable { let item: MessageItem let bubbleColor: Color + /// Color for the in-bubble send time. Passed in (not read from the theme + /// environment) for the same reason as `bubbleColor`: keep body invalidation + /// off the per-cell env-read path. + let timeColor: Color let callbacks: MessageBubbleCallbacks let imageResolver: (ImageReference) -> UIImage? @Environment(\.dynamicTypeSize) private var dynamicTypeSize nonisolated static func == (lhs: BubbleFragmentStack, rhs: BubbleFragmentStack) -> Bool { - lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor + lhs.item == rhs.item && lhs.bubbleColor == rhs.bubbleColor && lhs.timeColor == rhs.timeColor } private var hasFooter: Bool { @@ -52,13 +56,23 @@ struct BubbleFragmentStack: View, Equatable { var body: some View { let stack = VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 4) { + // Stack alignment carries the footer placement: the time sits at the + // bubble's trailing edge for outgoing, leading for incoming. Driving it + // through alignment (rather than a greedy `Spacer`/`maxWidth`) keeps the + // bubble hugging its content — a flexible-width child here leaves the + // self-sizing hosting cell without a resolvable intrinsic width, which + // SwiftUI surfaces as a fatal "invalid reuse after initialization failure". + VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 4) { if let textPayload { MessageTextView(text: textPayload) } - if !item.envelope.isOutgoing && hasFooter { - BubbleFooterRow(footer: item.footer, dynamicTypeSize: dynamicTypeSize) + if hasFooter { + BubbleFooterRow( + footer: item.footer, + dynamicTypeSize: dynamicTypeSize, + timeColor: timeColor + ) } } .bubbleContentPadding() diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index 9f7f91240..38b13d7f9 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -80,12 +80,6 @@ struct UnifiedMessageBubble: View, Equatable { MessageDayDividerView(date: item.envelope.date) } - // The day divider already anchors the cluster in time, so a time-only - // marker directly beneath it would be redundant; suppress it there. - if item.grouping.showTimestamp && !item.grouping.showDayDivider { - MessageTimestampView(date: item.envelope.date) - } - HStack(alignment: .bottom, spacing: 4) { if item.envelope.isOutgoing { Spacer(minLength: 40) @@ -105,11 +99,16 @@ struct UnifiedMessageBubble: View, Equatable { FallbackMatchIndicatorView() } } + // Indent the name to clear the bubble's rounded corner and + // leave breathing room above the bubble it labels. + .padding(.leading, 4) + .padding(.bottom, 3) } BubbleFragmentStack( item: item, bubbleColor: resolvedBubbleColor, + timeColor: footerTimeColor, callbacks: callbacks, imageResolver: imageResolver ) @@ -269,9 +268,19 @@ struct UnifiedMessageBubble: View, Equatable { ) } + /// Color for the send time rendered inside the bubble. `.secondary` reads + /// well on the gray incoming bubble; on the accent-colored outgoing bubble it + /// would wash out, so use a translucent outgoing-text color instead. + private var footerTimeColor: Color { + item.envelope.isOutgoing ? theme.outgoingTextColor.opacity(0.7) : .secondary + } + private var paddingTop: CGFloat { - if item.grouping.showDirectionGap { return 6 } - if item.grouping.showSenderName { return 4 } + // A direction switch (someone else replying) is the strongest visual + // break, then a new named sender within a channel. Same-sender follow-ups + // stay tightly stacked. + if item.grouping.showDirectionGap { return 14 } + if item.grouping.showSenderName { return 8 } return item.envelope.isOutgoing ? 1 : 2 } diff --git a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift index 3ec78dc62..7af32d9bf 100644 --- a/MC1/Views/Settings/Sections/MessagesSettingsSection.swift +++ b/MC1/Views/Settings/Sections/MessagesSettingsSection.swift @@ -4,14 +4,12 @@ import SwiftUI /// Chat settings section for incoming message routing info display struct MessagesSettingsSection: View { @Environment(\.appTheme) private var theme - @AppStorage(AppStorageKey.showIncomingSendTime.rawValue) private var showIncomingSendTime = false @AppStorage(AppStorageKey.showIncomingPath.rawValue) private var showIncomingPath = false @AppStorage(AppStorageKey.showIncomingHopCount.rawValue) private var showIncomingHopCount = false @AppStorage(AppStorageKey.showIncomingRegion.rawValue) private var showIncomingRegion = false var body: some View { Section { - Toggle(L10n.Settings.Messages.showIncomingSendTime, isOn: $showIncomingSendTime) Toggle(L10n.Settings.Messages.showIncomingPath, isOn: $showIncomingPath) Toggle(L10n.Settings.Messages.showIncomingHopCount, isOn: $showIncomingHopCount) Toggle(L10n.Settings.Messages.showIncomingRegion, isOn: $showIncomingRegion) diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index 114fe7cde..7f8de2dbb 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -213,14 +213,14 @@ public enum MessageFragmentBuilder { } else { region = nil } - // Send time shows on every incoming message (DM and channel) — unlike hop - // and region, it is not gated on `isFloodRouted`. Shows the clock-corrected - // `senderDate`, not the raw wire value, so a skewed sender clock doesn't put a - // misleading timestamp in the bubble; the badge flags the substitution and the - // raw value is available in the message info sheet. - let sendTimeToShow: Date? = - (envInputs.showIncomingSendTime && !message.isOutgoing) - ? message.senderDate : nil + // Send time shows inside every bubble — incoming and outgoing, DM and + // channel. It is the sole time surface now that the centered cluster + // marker is gone, so it is unconditional (no toggle, not gated on + // `isFloodRouted`). Shows the clock-corrected `senderDate`, not the raw + // wire value, so a skewed sender clock doesn't put a misleading timestamp + // in the bubble; the badge flags the substitution and the raw value is + // available in the message info sheet. + let sendTimeToShow: Date? = message.senderDate return MessageFooter( showHop: showHop, hopCount: message.hopCount, diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift index a284204e1..e8fca4b4f 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift @@ -227,36 +227,37 @@ struct MessageFragmentBuilderTests { // MARK: - Incoming send time - @Test("footer shows send time on incoming message when enabled") - func footer_showsSendTime_whenEnabledAndIncoming() { + @Test("footer shows send time on incoming message") + func footer_showsSendTime_onIncoming() { let wire: UInt32 = 1_700_000_000 let message = makeIncomingMessage(timestamp: wire) let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( - for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: true) + for: message, inputs: inputs, envInputs: makeEnvInputs() ) #expect(item.footer.sendTimeToShow == Date(timeIntervalSince1970: TimeInterval(wire))) #expect(item.footer.sendTimeWasCorrected == false) } - @Test("footer hides send time when the toggle is off") - func footer_hidesSendTime_whenDisabled() { - let message = makeIncomingMessage(timestamp: 1_700_000_000) + @Test("footer shows send time regardless of the legacy showIncomingSendTime flag") + func footer_showsSendTime_ignoringLegacyFlag() { + let wire: UInt32 = 1_700_000_000 + let message = makeIncomingMessage(timestamp: wire) let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: false) ) - #expect(item.footer.sendTimeToShow == nil) + #expect(item.footer.sendTimeToShow == Date(timeIntervalSince1970: TimeInterval(wire))) } - @Test("footer hides send time on outgoing messages even when enabled") - func footer_hidesSendTime_forOutgoing() { + @Test("footer shows send time on outgoing messages") + func footer_showsSendTime_forOutgoing() { let message = makeMessage(text: "hi") // outgoing by default let inputs = makeInputs(messageID: message.id) let item = MessageFragmentBuilder.makeItem( - for: message, inputs: inputs, envInputs: makeEnvInputs(showIncomingSendTime: true) + for: message, inputs: inputs, envInputs: makeEnvInputs() ) - #expect(item.footer.sendTimeToShow == nil) + #expect(item.footer.sendTimeToShow == message.senderDate) } @Test("footer send time uses the corrected value and flags correction")