Refine Mail thread layout for clearer grouping and tighter spacing

This commit is contained in:
2026-04-19 20:16:51 +02:00
parent 7631380215
commit 0c3e5d772c
2 changed files with 233 additions and 126 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ struct LaneChip: View {
} }
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.padding(.horizontal, 9) .padding(.horizontal, 9)
.padding(.vertical, 5) .padding(.vertical, 3)
.background(lane.color.opacity(0.14), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous)) .background(lane.color.opacity(0.14), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(lane.color) .foregroundStyle(lane.color)
} }
+232 -125
View File
@@ -137,9 +137,11 @@ struct MailRootView: View {
preferredCompactColumn: $preferredCompactColumn preferredCompactColumn: $preferredCompactColumn
) { ) {
SidebarView(model: model) SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 280)
} content: { } content: {
ThreadListView(model: model, layoutMode: .regular) ThreadListView(model: model, layoutMode: .regular)
.navigationTitle(model.selectedMailbox.title) .navigationTitle(model.selectedMailbox.title)
.navigationSplitViewColumnWidth(min: 380, ideal: 420, max: 520)
} detail: { } detail: {
if model.isComposing { if model.isComposing {
ComposeView(model: model) ComposeView(model: model)
@@ -446,38 +448,65 @@ struct ThreadListView: View {
) )
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
List(model.filteredThreads) { thread in ScrollView {
Button { LazyVStack(alignment: .leading, spacing: 20) {
Haptics.selection() ForEach(groupedThreads(model.filteredThreads)) { group in
model.openThread(withID: thread.id) VStack(alignment: .leading, spacing: 6) {
} label: { Text(group.title.uppercased())
ThreadRow( .font(.footnote.weight(.semibold))
thread: thread, .foregroundStyle(.secondary)
density: density, .kerning(0.4)
isSelected: thread.id == model.selectedThreadID .padding(.horizontal, 28)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.contextMenu {
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
model.toggleRead(for: thread)
}
Button(thread.isStarred ? "Remove Star" : "Star") { VStack(spacing: 0) {
model.toggleStar(for: thread) ForEach(Array(group.threads.enumerated()), id: \.element.id) { index, thread in
} Button {
Haptics.selection()
model.openThread(withID: thread.id)
} label: {
ThreadRow(
thread: thread,
density: density,
isSelected: thread.id == model.selectedThreadID
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
model.toggleRead(for: thread)
}
Button(thread.isStarred ? "Remove Star" : "Star") {
model.toggleStar(for: thread)
}
Button("Archive") {
model.moveThread(withID: thread.id, to: .archive)
}
}
Button("Archive") { if index < group.threads.count - 1 {
model.moveThread(withID: thread.id, to: .archive) Rectangle()
.fill(Color.primary.opacity(0.08))
.frame(height: 0.5)
.padding(.leading, 60)
}
}
}
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(platformSurfaceColor)
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
.padding(.horizontal, 16)
}
} }
} }
.padding(.vertical, 12)
.padding(.bottom, layoutMode == .compact ? 80 : 12)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
} }
} }
} }
@@ -500,6 +529,45 @@ struct ThreadListView: View {
return .comfortable return .comfortable
#endif #endif
} }
private func groupedThreads(_ threads: [MailThread]) -> [ThreadGroup] {
let calendar = Calendar.current
let now = Date()
let startOfToday = calendar.startOfDay(for: now)
let startOfWeek = calendar.date(byAdding: .day, value: -6, to: startOfToday) ?? startOfToday
let startOfMonth = calendar.date(byAdding: .day, value: -30, to: startOfToday) ?? startOfToday
var today: [MailThread] = []
var thisWeek: [MailThread] = []
var thisMonth: [MailThread] = []
var older: [MailThread] = []
for thread in threads {
let d = thread.lastUpdated
if d >= startOfToday {
today.append(thread)
} else if d >= startOfWeek {
thisWeek.append(thread)
} else if d >= startOfMonth {
thisMonth.append(thread)
} else {
older.append(thread)
}
}
var groups: [ThreadGroup] = []
if !today.isEmpty { groups.append(ThreadGroup(title: "Today", threads: today)) }
if !thisWeek.isEmpty { groups.append(ThreadGroup(title: "This week", threads: thisWeek)) }
if !thisMonth.isEmpty { groups.append(ThreadGroup(title: "Earlier this month", threads: thisMonth)) }
if !older.isEmpty { groups.append(ThreadGroup(title: "Older", threads: older)) }
return groups
}
}
private struct ThreadGroup: Identifiable {
let title: String
let threads: [MailThread]
var id: String { title }
} }
private struct ThreadListSearchHeader: View { private struct ThreadListSearchHeader: View {
@@ -651,21 +719,19 @@ private struct FloatingComposeButton: View {
Button { Button {
model.startCompose() model.startCompose()
} label: { } label: {
HStack(spacing: 10) { Image(systemName: "square.and.pencil")
Image(systemName: "square.and.pencil") .font(.system(size: 22, weight: .semibold))
Text("Compose") .foregroundStyle(.white)
} .frame(width: 56, height: 56)
.font(.headline.weight(.semibold)) .background(SIO.tint, in: Circle())
.foregroundStyle(.white) .shadow(color: SIO.tint.opacity(0.45), radius: 12, x: 0, y: 8)
.padding(.horizontal, 18) .shadow(color: SIO.tint.opacity(0.30), radius: 3, x: 0, y: 2)
.padding(.vertical, 14)
.sioGlassSurface(in: Capsule(), tint: SIO.tint)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Compose")
} }
.padding(.horizontal, 16) .padding(.horizontal, 18)
.padding(.top, 8) .padding(.bottom, 4)
.padding(.bottom, 12)
} }
} }
@@ -694,15 +760,15 @@ private struct CompactLaneOverview: View {
.foregroundStyle(model.laneFilter == lane ? lane.color : Color.primary) .foregroundStyle(model.laneFilter == lane ? lane.color : Color.primary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14) .padding(.horizontal, 12)
.padding(.vertical, 12) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(model.laneFilter == lane ? lane.color.opacity(0.12) : Color.secondary.opacity(0.08)) .fill(model.laneFilter == lane ? lane.color.opacity(0.10) : platformSurfaceColor)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.06), lineWidth: 1) .strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.10), lineWidth: 0.5)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -717,83 +783,83 @@ private struct ThreadRow: View {
let isSelected: Bool let isSelected: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 10) {
HStack(alignment: .top, spacing: 10) { unreadDot
unreadDot
AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize) AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(senderName) Text(senderName)
.font(.subheadline.weight(thread.isUnread ? .bold : .semibold)) .font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
.foregroundStyle(isSelected ? SIO.tint : Color.primary) .foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1) .lineLimit(1)
.layoutPriority(1)
if thread.messageCount > 1 { if thread.messageCount > 1 {
Text(thread.messageCount, format: .number) Text(thread.messageCount, format: .number)
.font(.caption2.weight(.bold)) .font(.caption2.weight(.bold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 6) .padding(.horizontal, 5)
.padding(.vertical, 2) .padding(.vertical, 1)
.background(Color.secondary.opacity(0.12), in: Capsule()) .background(Color.secondary.opacity(0.12), in: Capsule())
} .fixedSize()
}
Spacer(minLength: 0) Spacer(minLength: 4)
if thread.isStarred { if thread.isStarred {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.foregroundStyle(.yellow) .font(.caption)
} .foregroundStyle(.yellow)
}
if thread.hasAttachments { if thread.hasAttachments {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.foregroundStyle(.secondary)
}
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(thread.subject) Text(shortTimeLabel)
.font(.headline) .font(.caption)
.lineLimit(1)
Text(thread.previewText)
.font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(density.previewLineLimit) .lineLimit(1)
.fixedSize()
}
if density.showsMetaChips { Text(thread.subject)
HStack(spacing: 8) { .font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
LaneChip(lane: thread.lane) .foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1)
if thread.summary != nil { Text(thread.previewText)
HStack(spacing: 6) { .font(.footnote)
Image(systemName: "sparkles") .foregroundStyle(.secondary)
Text("AI Summary") .lineLimit(density.previewLineLimit)
}
.font(.caption.weight(.semibold)) if density.showsMetaChips {
.padding(.horizontal, 8) HStack(spacing: 6) {
.padding(.vertical, 5) LaneChip(lane: thread.lane)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(SIO.tint) if thread.summary != nil {
HStack(spacing: 4) {
Image(systemName: "sparkles")
Text("Summary")
} }
.font(.caption2.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(SIO.tint)
} }
} }
.padding(.top, 3)
} }
} }
} }
.padding(density.rowPadding) .padding(.horizontal, 14)
.background( .padding(.vertical, 12)
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) .background(isSelected ? SIO.tint.opacity(0.10) : Color.clear)
.fill(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(isSelected ? SIO.tint.opacity(0.18) : Color.primary.opacity(0.06), lineWidth: 1)
)
.accessibilityIdentifier("thread.\(thread.routeID)") .accessibilityIdentifier("thread.\(thread.routeID)")
} }
@@ -801,6 +867,27 @@ private struct ThreadRow: View {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown Sender" thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown Sender"
} }
private var shortTimeLabel: String {
let calendar = Calendar.current
let now = Date()
let date = thread.lastUpdated
if calendar.isDateInToday(date) {
return date.formatted(date: .omitted, time: .shortened)
}
if calendar.isDateInYesterday(date) {
return "Yesterday"
}
let daysAgo = calendar.dateComponents([.day], from: date, to: now).day ?? 0
if daysAgo < 7 {
return date.formatted(.dateTime.weekday(.abbreviated))
}
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
if sameYear {
return date.formatted(.dateTime.day().month(.abbreviated))
}
return date.formatted(.dateTime.day().month(.abbreviated).year(.twoDigits))
}
@ViewBuilder @ViewBuilder
private var unreadDot: some View { private var unreadDot: some View {
Circle() Circle()
@@ -906,36 +993,48 @@ private struct ReadingHeader: View {
let thread: MailThread let thread: MailThread
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) {
Text(thread.subject) Text(thread.subject)
.font(.title.weight(.bold)) .font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 12) {
participantStack
HStack(alignment: .center, spacing: 10) {
LaneChip(lane: thread.lane) LaneChip(lane: thread.lane)
Text("\(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")") Text("·")
.foregroundStyle(.secondary)
Text("\(thread.participants.count) people · \(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer(minLength: 0) Spacer(minLength: 0)
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened)) Text(readingTimeLabel)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
} }
} }
.padding(18) .padding(.horizontal, 2)
.sioCardBackground(tint: thread.lane.color)
} }
private var participantStack: some View { private var readingTimeLabel: String {
HStack(spacing: -10) { let calendar = Calendar.current
ForEach(Array(thread.participants.prefix(3))) { participant in let now = Date()
AvatarView(name: participant.name, color: thread.lane.color, size: 28) let date = thread.lastUpdated
} if calendar.isDateInToday(date) {
return "Today · " + date.formatted(date: .omitted, time: .shortened)
} }
if calendar.isDateInYesterday(date) {
return "Yesterday · " + date.formatted(date: .omitted, time: .shortened)
}
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
let datePart = sameYear
? date.formatted(.dateTime.day().month(.abbreviated))
: date.formatted(.dateTime.day().month(.abbreviated).year())
return datePart + " · " + date.formatted(date: .omitted, time: .shortened)
} }
} }
@@ -1054,14 +1153,14 @@ private struct MessageCard: View {
} }
} }
} }
.padding(18) .padding(14)
.background( .background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isFocused ? SIO.tint.opacity(0.12) : (isLatest ? SIO.tint.opacity(0.08) : Color.secondary.opacity(0.05))) .fill(isFocused ? SIO.tint.opacity(0.08) : platformSurfaceColor)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 1) .strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.10), lineWidth: 0.5)
) )
.accessibilityIdentifier("message.\(message.routeID)") .accessibilityIdentifier("message.\(message.routeID)")
} }
@@ -1090,14 +1189,20 @@ private struct InlineReplyComposer: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color.clear) .background(Color.clear)
} }
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .background(platformSurfaceColor, in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 6)
Button(action: onSend) { Button(action: onSend) {
Image(systemName: "arrow.up") Image(systemName: "paperplane.fill")
.font(.headline.weight(.bold)) .font(.headline.weight(.bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 42, height: 42) .frame(width: 48, height: 48)
.background(SIO.tint, in: Circle()) .background(SIO.tint, in: Circle())
.shadow(color: SIO.tint.opacity(0.4), radius: 10, x: 0, y: 6)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -1779,8 +1884,10 @@ private struct AppearanceSettingsContent: View {
#if os(macOS) #if os(macOS)
private let platformBackground = Color(nsColor: .windowBackgroundColor) private let platformBackground = Color(nsColor: .windowBackgroundColor)
private let platformSurfaceColor = Color(nsColor: .controlBackgroundColor)
#else #else
private let platformBackground = Color(uiColor: .systemBackground) private let platformBackground = Color(uiColor: .systemBackground)
private let platformSurfaceColor = Color(uiColor: .secondarySystemGroupedBackground)
#endif #endif
private extension View { private extension View {