From 9c56073900c4454c4c12b4468ce86548d4fe285f Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Fri, 17 Apr 2026 11:07:25 -0400 Subject: [PATCH] feat(contacts): Add dedicated lastHeardTimestamp for nodes - with fallback to lastModified Add dedicated lastHeardTimestamp field for radio reception tracking Replace reliance on lastModified (which updates on sync, path changes, and other non-radio events) with a dedicated lastHeardTimestamp that is only set when we actually confirm a node is alive over the radio: - Advertisement receipt (known, unknown, and deferred contacts) - Path discovery responses - Ping responses - Incoming direct messages Add effectiveLastHeard computed property on ContactDTO that falls back to lastModified when lastHeardTimestamp is unset, ensuring existing contacts display reasonable timestamps during the transition. Rename "Last Advert" label to "Node Time Sent" and add new "Last Heard" row in ContactDetailView and ContactDetailSheet. Update ContactRowView and sort-by-last-heard to use effectiveLastHeard. Updated locales with the changes. --- MC1/Resources/Generated/L10n.swift | 12 ++++--- .../Localization/de.lproj/Contacts.strings | 7 ++-- .../Localization/de.lproj/Map.strings | 7 ++-- .../Localization/en.lproj/Contacts.strings | 7 ++-- .../Localization/en.lproj/Map.strings | 7 ++-- .../Localization/es.lproj/Contacts.strings | 7 ++-- .../Localization/es.lproj/Map.strings | 7 ++-- .../Localization/fr.lproj/Contacts.strings | 7 ++-- .../Localization/fr.lproj/Map.strings | 7 ++-- .../Localization/nl.lproj/Contacts.strings | 7 ++-- .../Localization/nl.lproj/Map.strings | 7 ++-- .../Localization/pl.lproj/Contacts.strings | 7 ++-- .../Localization/pl.lproj/Map.strings | 7 ++-- .../Localization/ru.lproj/Contacts.strings | 7 ++-- .../Localization/ru.lproj/Map.strings | 7 ++-- .../Localization/uk.lproj/Contacts.strings | 7 ++-- .../Localization/uk.lproj/Map.strings | 7 ++-- .../zh-Hans.lproj/Contacts.strings | 7 ++-- .../Localization/zh-Hans.lproj/Map.strings | 7 ++-- MC1/Views/Contacts/ContactDetailView.swift | 16 ++++++++- MC1/Views/Contacts/ContactRowView.swift | 2 +- MC1/Views/Contacts/ContactsViewModel.swift | 2 +- MC1/Views/Map/ContactDetailSheet.swift | 4 +++ .../Sources/MC1Services/Models/Contact.swift | 18 ++++++++++ .../Protocols/PersistenceStoreProtocol.swift | 3 ++ .../Services/AdvertisementService.swift | 20 ++++++++++- .../Services/PersistenceStore+Contacts.swift | 16 +++++++++ .../SyncCoordinator+MessageHandlers.swift | 6 ++++ .../Helpers/ContactDTO+Testing.swift | 2 ++ .../Mocks/MockPersistenceStore.swift | 33 +++++++++++++++++++ MC1Tests/Services/LinkPreviewCacheTests.swift | 1 + .../ChatViewModelPaginationTests.swift | 1 + .../LineOfSightViewModelTests.swift | 1 + 33 files changed, 219 insertions(+), 44 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 2eb27435d..d08bd251d 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1069,8 +1069,10 @@ public enum L10n { public static let info = L10n.tr("Contacts", "contacts.detail.info", fallback: "Info") /// Location: ContactDetailView.swift - Purpose: Join room button public static let joinRoom = L10n.tr("Contacts", "contacts.detail.joinRoom", fallback: "Join Room") - /// Location: ContactDetailView.swift - Purpose: Last advert label - public static let lastAdvert = L10n.tr("Contacts", "contacts.detail.lastAdvert", fallback: "Last Advert") + /// Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) + public static let lastAdvert = L10n.tr("Contacts", "contacts.detail.lastAdvert", fallback: "Node Time Sent") + /// Location: ContactDetailView.swift - Purpose: Last heard label (when we received the node's advertisement) + public static let lastHeard = L10n.tr("Contacts", "contacts.detail.lastHeard", fallback: "Last Heard") /// Location: ContactDetailView.swift - Purpose: Location section header public static let location = L10n.tr("Contacts", "contacts.detail.location", fallback: "Location") /// Location: ContactDetailView.swift - Purpose: Management button @@ -2075,8 +2077,10 @@ public enum L10n { } /// Location: MapView.swift ContactDetailSheet - Purpose: Path length value for single hop public static let hopSingular = L10n.tr("Map", "map.detail.hopSingular", fallback: "1 hop") - /// Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp - public static let lastAdvert = L10n.tr("Map", "map.detail.lastAdvert", fallback: "Last Advert") + /// Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp + public static let lastAdvert = L10n.tr("Map", "map.detail.lastAdvert", fallback: "Node Time Sent") + /// Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node + public static let lastHeard = L10n.tr("Map", "map.detail.lastHeard", fallback: "Last Heard") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for latitude coordinate public static let latitude = L10n.tr("Map", "map.detail.latitude", fallback: "Latitude") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for longitude coordinate diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 79c6f08ad..9b312f068 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Name"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Knotenzeitstempel"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Zuletzt gehört"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ungelesene Nachrichten"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 999085b82..0a7485fc8 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Favorit"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Knotenzeitstempel"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Zuletzt gehört"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Standort"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 513ee3f1a..9526e3ea9 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Name"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Last Advert"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Node Time Sent"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label (when we received the node's advertisement) */ +"contacts.detail.lastHeard" = "Last Heard"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Unread Messages"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index 443e3454f..b7f55a118 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Favorite"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Last Advert"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Node Time Sent"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Last Heard"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Location"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 12af65d40..3a0e364fb 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Nombre"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Último anuncio"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Hora envío nodo"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Última recepción"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Mensajes sin leer"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index cc3e6934c..5576732a4 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Favorito"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Último anuncio"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Hora envío nodo"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Última recepción"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Ubicación"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 852c87ef5..f1b24a214 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Nom"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Dernière annonce"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Heure envoi nœud"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Dernier contact"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Messages non lus"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 07a145d6a..c179fcbde 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Favori"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Dernière annonce"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Heure envoi nœud"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Dernier contact"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Position"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 259ea42a3..2c911a6d9 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Naam"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Laatste advertentie"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Tijdstip node verzonden"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Laatst gehoord"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ongelezen berichten"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index f7857235b..b12d5fc2d 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Favoriet"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Laatste advertentie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Tijdstip node verzonden"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Laatst gehoord"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Locatie"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 2ea3105f4..f3a713d9d 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Nazwa"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Czas wysłania węzła"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Ostatnio odebrano"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Nieprzeczytane wiadomości"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index a5e5b553d..df99f4af8 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Ulubiony"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Czas wysłania węzła"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Ostatnio odebrano"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Lokalizacja"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 68e4e9601..c71ea8c7c 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Имя"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Последнее объявление"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Время отправки узла"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Последний приём"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитанные сообщения"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index be54431dd..85fa8b762 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "В избранном"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Последний сигнал"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Время отправки узла"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Последний приём"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Местоположение"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 722635208..f7920fde2 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "Ім'я"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "Останнє оголошення"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "Час відправки вузла"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "Останній прийом"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитані повідомлення"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index cdf012004..4e2f9403a 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "Обраний"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "Останнє оголошення"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "Час відправки вузла"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "Останній прийом"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "Місцезнаходження"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 6dc17748e..f3a2036f5 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -266,8 +266,11 @@ /* Location: ContactDetailView.swift - Purpose: Name label */ "contacts.detail.name" = "名称"; -/* Location: ContactDetailView.swift - Purpose: Last advert label */ -"contacts.detail.lastAdvert" = "最近广播"; +/* Location: ContactDetailView.swift - Purpose: Node time sent label (device-reported timestamp) */ +"contacts.detail.lastAdvert" = "节点发送时间"; + +/* Location: ContactDetailView.swift - Purpose: Last heard label */ +"contacts.detail.lastHeard" = "最近收到"; /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "未读消息"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index 3bcfa42e0..55c617c7d 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -75,8 +75,11 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ "map.detail.favorite" = "收藏"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ -"map.detail.lastAdvert" = "最后广播"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for node-reported timestamp */ +"map.detail.lastAdvert" = "节点发送时间"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for when we last heard the node */ +"map.detail.lastHeard" = "最近收到"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ "map.detail.section.location" = "位置"; diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 4c5d26108..f818abcd4 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -441,6 +441,13 @@ struct ContactDetailView: View { let latencyMs = Int(elapsed / .milliseconds(1)) pingResult = .success(latencyMs: latencyMs, snrThere: snrThere, snrBack: snrBack) + + // Ping response confirms the node is alive + try? await appState.services?.dataStore.updateContactLastHeard( + contactID: currentContact.id, + timestamp: UInt32(Date().timeIntervalSince1970) + ) + let announcement = L10n.Contacts.Contacts.Detail.pingSuccessAnnouncement(latencyMs) AccessibilityNotification.Announcement(announcement).post() } catch { @@ -758,7 +765,7 @@ private struct ContactInfoSection: View { .foregroundStyle(.secondary) } - // Last advert + // Node time sent (device-reported timestamp) if currentContact.lastAdvertTimestamp > 0 { HStack { Text(L10n.Contacts.Contacts.Detail.lastAdvert) @@ -767,6 +774,13 @@ private struct ContactInfoSection: View { } } + // Last heard (when we actually received the advertisement) + HStack { + Text(L10n.Contacts.Contacts.Detail.lastHeard) + Spacer() + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(currentContact.effectiveLastHeard)), font: .body) + } + // Unread count if currentContact.unreadCount > 0 { HStack { diff --git a/MC1/Views/Contacts/ContactRowView.swift b/MC1/Views/Contacts/ContactRowView.swift index ae0ca990e..5a5a2c1d8 100644 --- a/MC1/Views/Contacts/ContactRowView.swift +++ b/MC1/Views/Contacts/ContactRowView.swift @@ -52,7 +52,7 @@ struct ContactRowView: View { .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) } - RelativeTimestampText(timestamp: contact.lastModified) + RelativeTimestampText(timestamp: contact.effectiveLastHeard) } HStack(spacing: 8) { diff --git a/MC1/Views/Contacts/ContactsViewModel.swift b/MC1/Views/Contacts/ContactsViewModel.swift index 63b726a47..2172cdec9 100644 --- a/MC1/Views/Contacts/ContactsViewModel.swift +++ b/MC1/Views/Contacts/ContactsViewModel.swift @@ -272,7 +272,7 @@ final class ContactsViewModel { ) -> [ContactDTO] { switch order { case .lastHeard: - return contacts.sorted { $0.lastModified > $1.lastModified } + return contacts.sorted { $0.effectiveLastHeard > $1.effectiveLastHeard } case .name: return contacts.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index 1a535b687..fd812878e 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -61,6 +61,10 @@ struct ContactDetailSheet: View { ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) } } + + LabeledContent(L10n.Map.Map.Detail.lastHeard) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.effectiveLastHeard)), font: .body) + } } // Location section diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 7dc354550..e2d07d48c 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -48,6 +48,9 @@ public final class Contact { /// Last modification timestamp (for sync watermarking) public var lastModified: UInt32 + /// Timestamp when we actually received an advertisement from this node (our local clock) + public var lastHeardTimestamp: UInt32 = 0 + /// Local nickname override (optional) public var nickname: String? @@ -88,6 +91,7 @@ public final class Contact { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32 = 0, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -110,6 +114,7 @@ public final class Contact { self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -132,6 +137,7 @@ public final class Contact { latitude = dto.latitude longitude = dto.longitude lastModified = dto.lastModified + lastHeardTimestamp = dto.lastHeardTimestamp nickname = dto.nickname isBlocked = dto.isBlocked isMuted = dto.isMuted @@ -251,6 +257,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR public let latitude: Double public let longitude: Double public let lastModified: UInt32 + public let lastHeardTimestamp: UInt32 public let nickname: String? public let isBlocked: Bool public let isMuted: Bool @@ -274,6 +281,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR self.latitude = contact.latitude self.longitude = contact.longitude self.lastModified = contact.lastModified + self.lastHeardTimestamp = contact.lastHeardTimestamp self.nickname = contact.nickname self.isBlocked = contact.isBlocked self.isMuted = contact.isMuted @@ -299,6 +307,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR latitude: Double, longitude: Double, lastModified: UInt32, + lastHeardTimestamp: UInt32 = 0, nickname: String?, isBlocked: Bool, isMuted: Bool, @@ -321,6 +330,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -397,6 +407,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -411,6 +422,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -418,6 +430,12 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, RepeaterR ) } + /// The best "last heard" timestamp available. + /// Uses `lastHeardTimestamp` when set, otherwise falls back to `lastModified`. + public var effectiveLastHeard: UInt32 { + lastHeardTimestamp > 0 ? lastHeardTimestamp : lastModified + } + /// The active OCV array for this contact (preset or custom) public var activeOCVArray: [Int] { // If custom preset with valid custom string, parse it diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index 319db2c3b..2e7628f8c 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -146,6 +146,9 @@ public protocol PersistenceStoreProtocol: Actor { /// Update contact's last message info (nil clears the date, removing from conversations list) func updateContactLastMessage(contactID: UUID, date: Date?) async throws + /// Update the timestamp when we actually heard a node over radio + func updateContactLastHeard(contactID: UUID, timestamp: UInt32) async throws + /// Increment unread count for a contact func incrementUnreadCount(contactID: UUID) async throws diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index 1721cb378..ebf17ea0a 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -284,7 +284,10 @@ public actor AdvertisementService { longitude: contact.longitude, lastModified: UInt32(Date().timeIntervalSince1970) ) - _ = try await dataStore.saveContact(deviceID: deviceID, from: frame) + let contactID = try await dataStore.saveContact(deviceID: deviceID, from: frame) + + // Mark the actual time we heard this node over radio + try? await dataStore.updateContactLastHeard(contactID: contactID, timestamp: timestamp) // Also track in DiscoveredNode for Discover page visibility _ = try? await dataStore.upsertDiscoveredNode(deviceID: deviceID, from: frame) @@ -306,6 +309,9 @@ public actor AdvertisementService { let frame = meshContact.toContactFrame() let contactID = try await dataStore.saveContact(deviceID: deviceID, from: frame) + // Mark the actual time we heard this node over radio + try? await dataStore.updateContactLastHeard(contactID: contactID, timestamp: timestamp) + // Also track in DiscoveredNode for Discover page visibility _ = try? await dataStore.upsertDiscoveredNode(deviceID: deviceID, from: frame) @@ -343,6 +349,12 @@ public actor AdvertisementService { let frame = meshContact.toContactFrame() let contactID = try await dataStore.saveContact(deviceID: deviceID, from: frame) + // These contacts were heard over radio during sync + try? await dataStore.updateContactLastHeard( + contactID: contactID, + timestamp: UInt32(Date().timeIntervalSince1970) + ) + // Also track in DiscoveredNode for Discover page visibility _ = try? await dataStore.upsertDiscoveredNode(deviceID: deviceID, from: frame) @@ -446,6 +458,12 @@ public actor AdvertisementService { ) _ = try await dataStore.saveContact(deviceID: deviceID, from: frame) + // Path discovery response confirms the node is reachable + try? await dataStore.updateContactLastHeard( + contactID: contact.id, + timestamp: UInt32(Date().timeIntervalSince1970) + ) + // Path discovery success = we have a direct route now (not flood) let isNowFlood = false diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift index 5e4e1a048..18b00290e 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift @@ -133,6 +133,7 @@ extension PersistenceStore { latitude: dto.latitude, longitude: dto.longitude, lastModified: dto.lastModified, + lastHeardTimestamp: dto.lastHeardTimestamp, nickname: dto.nickname, isBlocked: dto.isBlocked, isFavorite: dto.isFavorite, @@ -188,6 +189,21 @@ extension PersistenceStore { } } + /// Update the timestamp when we actually heard a node over radio + public func updateContactLastHeard(contactID: UUID, timestamp: UInt32) throws { + let targetID = contactID + let predicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + if let contact = try modelContext.fetch(descriptor).first { + contact.lastHeardTimestamp = timestamp + try modelContext.save() + } + } + /// Increment unread count for a contact public func incrementUnreadCount(contactID: UUID) throws { let targetID = contactID diff --git a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift index 02f6126aa..a191e08fd 100644 --- a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift @@ -175,6 +175,12 @@ extension SyncCoordinator { // Update contact's last message date if let contactID = contact?.id { try await services.dataStore.updateContactLastMessage(contactID: contactID, date: Date()) + + // Receiving a DM confirms the node is alive + try? await services.dataStore.updateContactLastHeard( + contactID: contactID, + timestamp: UInt32(Date().timeIntervalSince1970) + ) } // Only increment unread count, post notification, and update badge for non-blocked contacts diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift index a64ac96a9..f30e050b7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift @@ -24,6 +24,7 @@ extension ContactDTO { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32 = 0, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -45,6 +46,7 @@ extension ContactDTO { latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 37e49c570..8d3467a13 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -544,6 +544,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -555,6 +556,33 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { } } + public func updateContactLastHeard(contactID: UUID, timestamp: UInt32) async throws { + if let contact = contacts[contactID] { + contacts[contactID] = ContactDTO( + id: contact.id, + deviceID: contact.deviceID, + publicKey: contact.publicKey, + name: contact.name, + typeRawValue: contact.typeRawValue, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified, + lastHeardTimestamp: timestamp, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: contact.lastMessageDate, + unreadCount: contact.unreadCount, + unreadMentionCount: contact.unreadMentionCount + ) + } + } + public func incrementUnreadCount(contactID: UUID) async throws { if let contact = contacts[contactID] { contacts[contactID] = ContactDTO( @@ -570,6 +598,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -596,6 +625,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -660,6 +690,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -686,6 +717,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -712,6 +744,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 0e3cc6bb6..e035e73a7 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -270,6 +270,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func updateContactLastHeard(contactID: UUID, timestamp: UInt32) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index a830da010..326e47cb5 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -233,6 +233,7 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws { contacts[dto.id] = dto } func deleteContact(id: UUID) async throws { contacts.removeValue(forKey: id) } func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func updateContactLastHeard(contactID: UUID, timestamp: UInt32) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 66d7636c1..12cee5305 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -99,6 +99,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} + func updateContactLastHeard(contactID: UUID, timestamp: UInt32) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} func markMentionSeen(messageID: UUID) async throws {}