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
14 changes: 12 additions & 2 deletions MC1/Views/Chats/Components/BubbleFooterRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -68,7 +78,7 @@ private struct BubbleSendTimeFooter: View {
Text(timeText)
}
.font(.caption2)
.foregroundStyle(.secondary)
.foregroundStyle(color)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityLabel)
}
Expand Down
22 changes: 18 additions & 4 deletions MC1/Views/Chats/Components/BubbleFragmentStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ 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 {
Expand Down Expand Up @@ -53,13 +57,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()
Expand Down
50 changes: 11 additions & 39 deletions MC1/Views/Chats/Components/MessageDayDividerView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

Expand All @@ -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()
Expand Down
22 changes: 0 additions & 22 deletions MC1/Views/Chats/Components/MessageTimestampView.swift

This file was deleted.

25 changes: 17 additions & 8 deletions MC1/Views/Chats/Components/UnifiedMessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,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)
Expand All @@ -82,11 +76,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
)
Expand Down Expand Up @@ -222,9 +221,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
}

Expand Down
2 changes: 0 additions & 2 deletions MC1/Views/Settings/Sections/MessagesSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 12 additions & 11 deletions MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading