diff --git a/PocketMesh/Extensions/View+ListChevron.swift b/PocketMesh/Extensions/View+ListChevron.swift new file mode 100644 index 000000000..cf2474bb5 --- /dev/null +++ b/PocketMesh/Extensions/View+ListChevron.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension View { + /// Adds a trailing chevron aligned with the top row of a list item. + /// Used with hidden NavigationLink pattern to control chevron positioning. + func listChevron(offset: CGFloat = -8) -> some View { + HStack(spacing: 12) { + self + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .offset(y: offset) + } + } +} diff --git a/PocketMesh/Views/Chats/ChannelConversationRow.swift b/PocketMesh/Views/Chats/ChannelConversationRow.swift index 7381ad542..6e1beb81e 100644 --- a/PocketMesh/Views/Chats/ChannelConversationRow.swift +++ b/PocketMesh/Views/Chats/ChannelConversationRow.swift @@ -9,6 +9,9 @@ struct ChannelConversationRow: View { var body: some View { HStack(spacing: 12) { ChannelAvatar(channel: channel, size: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: channel.unreadCount) + } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { @@ -16,17 +19,17 @@ struct ChannelConversationRow: View { .font(.headline) .lineLimit(1) - Spacer() - - NotificationLevelIndicator(level: channel.notificationLevel) - if channel.isFavorite { Image(systemName: "star.fill") .foregroundStyle(.yellow) - .font(.caption) + .font(.system(size: 13.2)) .accessibilityLabel(Strings.favorite) } + Spacer() + + NotificationLevelIndicator(level: channel.notificationLevel) + if let date = channel.lastMessageDate { ConversationTimestamp(date: date) } @@ -39,12 +42,6 @@ struct ChannelConversationRow: View { .lineLimit(1) Spacer() - - UnreadBadges( - unreadCount: channel.unreadCount, - unreadMentionCount: channel.unreadMentionCount, - notificationLevel: channel.notificationLevel - ) } } .alignmentGuide(.listRowSeparatorLeading) { d in diff --git a/PocketMesh/Views/Chats/ConversationListContent.swift b/PocketMesh/Views/Chats/ConversationListContent.swift index 3333fab52..15f203a3a 100644 --- a/PocketMesh/Views/Chats/ConversationListContent.swift +++ b/PocketMesh/Views/Chats/ConversationListContent.swift @@ -78,20 +78,26 @@ struct ConversationListContent: View { let route = ChatRoute(conversation: conversation) switch conversation { case .direct(let contact): - NavigationLink(value: route) { - ConversationRow(contact: contact, viewModel: viewModel) - } - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } + ConversationRow(contact: contact, viewModel: viewModel) + .listChevron(offset: -11) + .background { + NavigationLink(value: route) { EmptyView() } + .opacity(0) + } + .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { + onDeleteConversation(conversation) + } case .channel(let channel): - NavigationLink(value: route) { - ChannelConversationRow(channel: channel, viewModel: viewModel) - } - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } + ChannelConversationRow(channel: channel, viewModel: viewModel) + .listChevron(offset: -11) + .background { + NavigationLink(value: route) { EmptyView() } + .opacity(0) + } + .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { + onDeleteConversation(conversation) + } case .room(let session): Button { diff --git a/PocketMesh/Views/Chats/ConversationRow.swift b/PocketMesh/Views/Chats/ConversationRow.swift index c8071e91d..5933a2b0f 100644 --- a/PocketMesh/Views/Chats/ConversationRow.swift +++ b/PocketMesh/Views/Chats/ConversationRow.swift @@ -8,6 +8,9 @@ struct ConversationRow: View { var body: some View { HStack(spacing: 12) { ContactAvatar(contact: contact, size: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: contact.unreadCount) + } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { @@ -15,20 +18,20 @@ struct ConversationRow: View { .font(.headline) .lineLimit(1) - Spacer() - - MutedIndicator(isMuted: contact.isMuted) - if viewModel.togglingFavoriteID == contact.id { ProgressView() .controlSize(.small) } else if contact.isFavorite { Image(systemName: "star.fill") .foregroundStyle(.yellow) - .font(.caption) + .font(.system(size: 13.2)) .accessibilityLabel(L10n.Chats.Chats.Row.favorite) } + Spacer() + + MutedIndicator(isMuted: contact.isMuted) + if let date = contact.lastMessageDate { ConversationTimestamp(date: date) } @@ -41,12 +44,6 @@ struct ConversationRow: View { .lineLimit(1) Spacer() - - UnreadBadges( - unreadCount: contact.unreadCount, - unreadMentionCount: contact.unreadMentionCount, - notificationLevel: contact.isMuted ? .muted : .all - ) } } .alignmentGuide(.listRowSeparatorLeading) { d in diff --git a/PocketMesh/Views/Chats/ConversationTimestamp.swift b/PocketMesh/Views/Chats/ConversationTimestamp.swift index 505914308..44642d1da 100644 --- a/PocketMesh/Views/Chats/ConversationTimestamp.swift +++ b/PocketMesh/Views/Chats/ConversationTimestamp.swift @@ -18,9 +18,13 @@ struct ConversationTimestamp: View { if calendar.isDateInToday(date) { return date.formatted(date: .omitted, time: .shortened) } else if calendar.isDateInYesterday(date) { - return date.formatted(.relative(presentation: .named)) - } else { + return "Yesterday" + } else if let daysAgo = calendar.dateComponents([.day], from: date, to: now).day, daysAgo < 7 { + return date.formatted(.dateTime.weekday(.wide)) + } else if calendar.component(.year, from: date) == calendar.component(.year, from: now) { return date.formatted(.dateTime.month(.abbreviated).day()) + } else { + return date.formatted(.dateTime.month(.abbreviated).day().year()) } } } diff --git a/PocketMesh/Views/Chats/RoomConversationRow.swift b/PocketMesh/Views/Chats/RoomConversationRow.swift index 4ff60f202..2542563da 100644 --- a/PocketMesh/Views/Chats/RoomConversationRow.swift +++ b/PocketMesh/Views/Chats/RoomConversationRow.swift @@ -5,8 +5,11 @@ struct RoomConversationRow: View { let session: RemoteNodeSessionDTO var body: some View { - HStack(spacing: 12) { + HStack(alignment: .center, spacing: 12) { NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: session.unreadCount) + } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { @@ -14,17 +17,17 @@ struct RoomConversationRow: View { .font(.headline) .lineLimit(1) - Spacer() - - NotificationLevelIndicator(level: session.notificationLevel) - if session.isFavorite { Image(systemName: "star.fill") .foregroundStyle(.yellow) - .font(.caption) + .font(.system(size: 13.2)) .accessibilityLabel(L10n.Chats.Chats.Row.favorite) } + Spacer() + + NotificationLevelIndicator(level: session.notificationLevel) + if let date = session.lastMessageDate { ConversationTimestamp(date: date) } @@ -42,11 +45,6 @@ struct RoomConversationRow: View { } Spacer() - - UnreadBadges( - unreadCount: session.unreadCount, - notificationLevel: session.notificationLevel - ) } } .alignmentGuide(.listRowSeparatorLeading) { d in @@ -56,6 +54,7 @@ struct RoomConversationRow: View { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) + .offset(y: -11) } .padding(.vertical, 4) .contentShape(.rect) diff --git a/PocketMesh/Views/Components/RelativeTimestampText.swift b/PocketMesh/Views/Components/RelativeTimestampText.swift index aeed3b170..cd0b00cd1 100644 --- a/PocketMesh/Views/Components/RelativeTimestampText.swift +++ b/PocketMesh/Views/Components/RelativeTimestampText.swift @@ -4,15 +4,6 @@ import SwiftUI struct RelativeTimestampText: View { let timestamp: UInt32 - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() - - private static let weekThreshold: TimeInterval = 604_800 - private static let nowThreshold: TimeInterval = 60 - var body: some View { TimelineView(.everyMinute) { context in Text(Self.format(timestamp: timestamp, relativeTo: context.date)) @@ -24,17 +15,29 @@ struct RelativeTimestampText: View { /// Formats a timestamp relative to the given date. Exposed for testing. static func format(timestamp: UInt32, relativeTo now: Date) -> String { let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) - let interval = now.timeIntervalSince(date) + let calendar = Calendar.current + + let startOfNow = calendar.startOfDay(for: now) + let startOfDate = calendar.startOfDay(for: date) + let daysAgo = calendar.dateComponents([.day], from: startOfDate, to: startOfNow).day ?? 0 + + if daysAgo == 0 { + return date.formatted(date: .omitted, time: .shortened) + } + + if daysAgo == 1 { + return "Yesterday" + } - if interval < nowThreshold { - return L10n.Chats.Chats.Timestamp.now + if daysAgo < 7 { + return date.formatted(.dateTime.weekday(.wide)) } - if interval >= weekThreshold { + if calendar.component(.year, from: date) == calendar.component(.year, from: now) { return date.formatted(.dateTime.month(.abbreviated).day()) } - return relativeFormatter.localizedString(for: date, relativeTo: now) + return date.formatted(.dateTime.month(.abbreviated).day().year()) } } diff --git a/PocketMesh/Views/Components/UnreadCountBadge.swift b/PocketMesh/Views/Components/UnreadCountBadge.swift new file mode 100644 index 000000000..977fd2263 --- /dev/null +++ b/PocketMesh/Views/Components/UnreadCountBadge.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// Red badge with unread count, displayed as an overlay on avatars. +/// Matches iOS app icon badge style. +struct UnreadCountBadge: View { + let count: Int + + var body: some View { + if count > 0 { + Text(count, format: .number) + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(.red, in: .capsule) + .offset(x: 4, y: -4) + } + } +} + +#Preview { + VStack(spacing: 20) { + Circle() + .fill(.blue) + .frame(width: 44, height: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: 3) + } + + Circle() + .fill(.blue) + .frame(width: 44, height: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: 42) + } + + Circle() + .fill(.blue) + .frame(width: 44, height: 44) + .overlay(alignment: .topTrailing) { + UnreadCountBadge(count: 0) + } + } + .padding() +} diff --git a/PocketMesh/Views/Contacts/ContactRowView.swift b/PocketMesh/Views/Contacts/ContactRowView.swift index 6590076ca..f36ff78a9 100644 --- a/PocketMesh/Views/Contacts/ContactRowView.swift +++ b/PocketMesh/Views/Contacts/ContactRowView.swift @@ -40,18 +40,18 @@ struct ContactRowView: View { .accessibilityLabel(L10n.Contacts.Contacts.Row.blocked) } - Spacer() - if isTogglingFavorite { ProgressView() .controlSize(.small) } else if contact.isFavorite { Image(systemName: "star.fill") - .font(.caption) + .font(.system(size: 13.2)) .foregroundStyle(.yellow) .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) } + Spacer() + RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) } diff --git a/PocketMesh/Views/Contacts/ContactsCompactList.swift b/PocketMesh/Views/Contacts/ContactsCompactList.swift index 933ba06f8..07f95216f 100644 --- a/PocketMesh/Views/Contacts/ContactsCompactList.swift +++ b/PocketMesh/Views/Contacts/ContactsCompactList.swift @@ -20,14 +20,17 @@ struct ContactsCompactList: View { .listSectionSeparator(.hidden) ForEach(Array(filteredContacts.enumerated()), id: \.element.id) { index, contact in - NavigationLink(value: contact) { - ContactRowView( - contact: contact, - showTypeLabel: isSearching, - userLocation: appState.locationService.currentLocation, - index: index, - isTogglingFavorite: viewModel.togglingFavoriteID == contact.id - ) + ContactRowView( + contact: contact, + showTypeLabel: isSearching, + userLocation: appState.locationService.currentLocation, + index: index, + isTogglingFavorite: viewModel.togglingFavoriteID == contact.id + ) + .listChevron() + .background { + NavigationLink(value: contact) { EmptyView() } + .opacity(0) } .contactSwipeActions(contact: contact, viewModel: viewModel) } diff --git a/PocketMeshTests/Views/RelativeTimestampTextTests.swift b/PocketMeshTests/Views/RelativeTimestampTextTests.swift index 5ec3ebe9a..c8f655550 100644 --- a/PocketMeshTests/Views/RelativeTimestampTextTests.swift +++ b/PocketMeshTests/Views/RelativeTimestampTextTests.swift @@ -11,77 +11,62 @@ struct RelativeTimestampTextTests { UInt32(referenceDate.addingTimeInterval(-secondsAgo).timeIntervalSince1970) } - // MARK: - Now Threshold (< 60 seconds) + // MARK: - Same Day (clock time) - @Test("Returns 'Now' for timestamps under 60 seconds") - func format_justNow_returnsNow() { + @Test("Returns clock time for just now") + func format_justNow_returnsClockTime() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 0), relativeTo: referenceDate ) - #expect(result == L10n.Chats.Chats.Timestamp.now) + // Should return clock time like "3:42 PM", which contains ":" + #expect(result.contains(":")) } - @Test("Returns 'Now' at 59 seconds ago") - func format_59Seconds_returnsNow() { - let result = RelativeTimestampText.format( - timestamp: timestamp(secondsAgo: 59), - relativeTo: referenceDate - ) - #expect(result == L10n.Chats.Chats.Timestamp.now) - } - - @Test("Returns relative format at exactly 60 seconds") - func format_60Seconds_returnsRelative() { - let result = RelativeTimestampText.format( - timestamp: timestamp(secondsAgo: 60), - relativeTo: referenceDate - ) - #expect(result != L10n.Chats.Chats.Timestamp.now) - #expect(!result.isEmpty) - } - - // MARK: - Relative Times (1 min to 1 week) - - @Test("Returns non-empty string for minutes ago") - func format_minutesAgo_returnsNonEmpty() { + @Test("Returns clock time for minutes ago") + func format_minutesAgo_returnsClockTime() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 120), relativeTo: referenceDate ) - #expect(!result.isEmpty) + #expect(result.contains(":")) } - @Test("Returns non-empty string for hours ago") - func format_hoursAgo_returnsNonEmpty() { + @Test("Returns clock time for hours ago") + func format_hoursAgo_returnsClockTime() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 3600), relativeTo: referenceDate ) - #expect(!result.isEmpty) + #expect(result.contains(":")) } - @Test("Returns non-empty string for yesterday") - func format_yesterday_returnsNonEmpty() { + // MARK: - Yesterday + + @Test("Returns 'Yesterday' for yesterday") + func format_yesterday_returnsYesterday() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 86400), relativeTo: referenceDate ) - #expect(!result.isEmpty) + #expect(result == "Yesterday") } - @Test("Returns non-empty string for days ago") - func format_daysAgo_returnsNonEmpty() { + // MARK: - Day of Week (2-6 days ago) + + @Test("Returns full weekday name for 2 days ago") + func format_2DaysAgo_returnsWeekday() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 172800), relativeTo: referenceDate ) - #expect(!result.isEmpty) + let weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + #expect(weekdays.contains(result)) } // MARK: - Week+ (formatted date) - @Test("Returns abbreviated date format for 7+ days ago") + @Test("Returns abbreviated date format for 7+ days ago (same year)") func format_7DaysAgo_returnsFormattedDate() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 604800), @@ -91,8 +76,8 @@ struct RelativeTimestampTextTests { #expect(result.contains(" ")) } - @Test("Returns abbreviated date format for old dates") - func format_oldDate_returnsFormattedDate() { + @Test("Returns abbreviated date format for 30 days ago (same year)") + func format_30DaysAgo_returnsFormattedDate() { let result = RelativeTimestampText.format( timestamp: timestamp(secondsAgo: 2_592_000), // 30 days relativeTo: referenceDate @@ -101,21 +86,35 @@ struct RelativeTimestampTextTests { #expect(result.contains(" ")) } + // MARK: - Previous Year + + @Test("Returns date with year for previous year dates") + func format_previousYear_includesYear() { + // referenceDate is Nov 14, 2023 — go back ~365 days to 2022 + let result = RelativeTimestampText.format( + timestamp: timestamp(secondsAgo: 31_536_000), + relativeTo: referenceDate + ) + // Should include year, e.g., "Nov 14, 2022" + #expect(result.contains("2022")) + } + // MARK: - Boundary Tests - @Test("Uses relative format just before week threshold") - func format_justBeforeWeek_usesRelativeFormat() { + @Test("Uses weekday name just before week threshold") + func format_6DaysAgo_returnsWeekday() { let result = RelativeTimestampText.format( - timestamp: timestamp(secondsAgo: 604799), // 1 second before 7 days + timestamp: timestamp(secondsAgo: 518400), // 6 days relativeTo: referenceDate ) - #expect(!result.isEmpty) + let weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + #expect(weekdays.contains(result)) } - @Test("Uses date format at exactly week threshold") - func format_exactlyWeek_usesDateFormat() { + @Test("Uses date format at 7 days ago") + func format_7Days_usesDateFormat() { let result = RelativeTimestampText.format( - timestamp: timestamp(secondsAgo: 604800), // exactly 7 days + timestamp: timestamp(secondsAgo: 604800), // 7 days relativeTo: referenceDate ) // Date format should contain a space (e.g., "Nov 7")