diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 2eb27435..d08bd251 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 79c6f08a..9b312f06 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 999085b8..0a7485fc 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 513ee3f1..9526e3ea 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 443e3454..b7f55a11 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 12af65d4..3a0e364f 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 cc3e6934..5576732a 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 852c87ef..f1b24a21 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 07a145d6..c179fcbd 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 259ea42a..2c911a6d 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 f7857235..b12d5fc2 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 2ea3105f..f3a713d9 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 a5e5b553..df99f4af 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 68e4e960..c71ea8c7 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 be54431d..85fa8b76 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 72263520..f7920fde 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 cdf01200..4e2f9403 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 6dc17748..f3a2036f 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 3bcfa42e..55c617c7 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 4c5d2610..f818abcd 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 ae0ca990..5a5a2c1d 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 63b726a4..2172cdec 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 1a535b68..fd812878 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 7dc35455..e2d07d48 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 319db2c3..2e7628f8 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 1721cb37..ebf17ea0 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 5e4e1a04..18b00290 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 02f6126a..a191e08f 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 a64ac96a..f30e050b 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 37e49c57..8d3467a1 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 0e3cc6bb..e035e73a 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 a830da01..326e47cb 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 66d7636c..12cee530 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 {}