Empty-mesh liveliness: nearby conversations, echoes, wave, dead drops, radar (#1409)

* Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar

The empty mesh timeline was a dead end: a grey zero and "nobody in range
yet". This turns it into a live surface and gives the app pull when the
mesh wakes up:

- Nearest conversation: background geohash sampling now tracks actual
  chat messages (not just presence) per regional channel; the empty state
  surfaces the busiest nearby conversation with a preview, one tap to
  join (GeohashChatActivityTracker, fed from GeoPresenceTracker).
- Echoes: the carried 6h store-and-forward window renders as dimmed
  "heard here earlier" rows at launch (new Transport
  collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded
  with signature-derived nicknames; content-identity dedup guards
  against re-synced duplicates).
- Wave: the "bitchatters nearby" notification gains a "wave" quick
  action that broadcasts a mesh 👋 straight from the notification, even
  backgrounded (first UNNotificationCategory in the app).
- Dead drops: /drop pins a note to the current building geohash as a
  kind-1 location note with a 24h NIP-40 expiry; expired notes are now
  dropped client-side at ingest; the notices sheet shows "fades in Xh";
  a "location notes" toggle plus location-permission controls live in
  app info (also fixes the duplicated Voice section).
- Radar: an ambient sonar animation shows the radio scanning, with a
  privacy-safe daily tally ("N devices passed within range today" via
  salted per-day hashes) and a "notes left here" hint that opens the
  notices geo tab.

All new user-facing strings ship in all 29 locales. 1403 tests green,
including new suites for the activity tracker, sightings tally, and
note expiry/drop publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s

- App info: LOCATION header uppercased like sibling sections; location
  notes and live voice descriptions shortened (29 locales); redundant
  "location access granted" line removed.
- Notices parity: urgent + expiry controls now show on the geo tab too;
  the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side
  readers see both; urgent parsed back from incoming notes.
- Radar moved from the top of the empty state to the center of the chat
  area, below the help text (empty state fills the visible height).
- Header pin fills whenever the scope has notices (was: only unseen),
  and Nostr-only nearby notes now light it too.
- Codex P2 fixes: notification completion deferred until the wave action
  is handled (background suspension dropped the send); NIP-40 notes now
  prune on a timer when they expire while displayed; the location-notes
  kill switch retargets the nearby-notes counter immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in')

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Geo notes: permanent (∞) expiry default, urgent stays mesh-only

- Geo expiry picker gains ∞ as the default: a permanent note posts as a
  pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must
  fade within days, contradicting the ∞ the user picked). 1/3/7d keep
  the board + bridged-note path with NIP-40.
- The notes manager is now owned by the notices sheet (not the list) so
  the composer local-echoes ∞ notes into the list; it revives via
  refresh() after a tab-switch cancel, and its expiry-prune timer
  survives cancel (weak self, dies with the instance).
- Urgent toggle returns to mesh-only per review — notes are ambient;
  the read-side urgent-tag parse stays so tagged notes still render.
- macOS: hide the segmented picker's built-in label (duplicate
  "expires in").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clearing the mesh timeline dismisses echoes for good; tighter divider copy

Triple-tap /clear emptied the timeline but the next launch re-seeded
"heard here earlier" from the persisted archive. A MeshEchoSettings
watermark now records the clear; only messages heard after it come back
(the archive itself still carries everything for peers' sync). The
echo dedup keys reset with it, and panic wipe drops the watermark.

Divider copy tightened to "heard here earlier · last 6h" (29 locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Echoes visual polish: tinted history block, radar-captioned tally, ambient footer

Device-test feedback on the echoes screen:
- Archived echoes now sit on a subtle tinted background (secondary at
  8%) in addition to the dim, so "heard here earlier" reads as one
  distinct block; the divider carries the echo ID prefix to join it.
- "N devices passed within range today" moves out of the narration
  lines to sit centered under the radar as its caption.
- When the timeline holds only echoes/system lines, a compact ambient
  footer (small radar + tally + live hints) renders below the history
  instead of the whole ambient layer vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notes strip persists above the mesh chat; leaner empty-state narration

- The 📍 "notes left here" line was empty-state-only, so starting a
  conversation hid it. It is now a tappable strip pinned above the mesh
  timeline whenever unexpired notes exist at this place (opens the
  notices geo tab); the nearby-notes counter runs for the whole mesh
  timeline, not just the empty state.
- Empty state narration drops "nobody in range yet..." (the radar and
  the sightings caption already say it) and the nearby-conversation
  hint moves below the help line instead of splitting the narration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Radar means searching: hide the sweep once mesh peers are connected or reachable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-08 13:24:44 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent ef848857b7
commit 74b8a47d02
37 changed files with 4633 additions and 100 deletions
+9
View File
@@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject {
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var isNoticesSheetPresented = false
/// When the sheet is opened for "notes left here" (empty mesh timeline),
/// it should land on the geo tab instead of the channel-derived default.
@Published var noticesSheetPrefersGeoTab = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@@ -62,6 +66,11 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
func presentNotices(geoTab: Bool = false) {
noticesSheetPrefersGeoTab = geoTab
isNoticesSheetPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
+10 -1
View File
@@ -217,7 +217,16 @@ final class AppRuntime: ObservableObject {
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
func handleNotificationResponse(
identifier: String,
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
}
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
+89
View File
@@ -0,0 +1,89 @@
//
// NearbyNotesCounter.swift
// bitchat
//
// Counts unexpired location notes left at the user's current building-level
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
// subscribes while a view holds it active, and only when location notes are
// enabled and location permission is already granted (it never prompts).
// This is free and unencumbered software released into the public domain.
//
import Combine
import Foundation
@MainActor
final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
init(locationManager: LocationChannelManager = .shared) {
self.locationManager = locationManager
}
/// Begins (or keeps) the notes subscription for the current building
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
/// hold it.
func activate() {
activeHolders += 1
guard activeHolders == 1 else { return }
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
retarget()
}
func deactivate() {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
settingCancellable = nil
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
return
}
if let manager {
manager.setGeohash(geohash)
return
}
let fresh = LocationNotesManager(geohash: geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
.sink { [weak self] notes in
let now = Date()
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
}
}
}
+10 -2
View File
@@ -104,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo
// Complete only after the response is handled: for a background
// action (👋 wave) the system may suspend the app the moment the
// completion handler runs, which would drop the queued send.
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
self.runtime?.handleNotificationResponse(
identifier: identifier,
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
completionHandler()
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable {
case unfavorite = "unfav"
case ping
case trace
case drop
var id: String { rawValue }
@@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable {
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .drop:
return "<" + String(localized: "content.input.note_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
case .drop: String(localized: "content.commands.drop")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
+5 -1
View File
@@ -264,7 +264,8 @@ struct NostrProtocol {
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
expiresAt: Date? = nil
expiresAt: Date? = nil,
urgent: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
@@ -273,6 +274,9 @@ struct NostrProtocol {
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
if urgent {
tags.append(["t", "urgent"])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
+50
View File
@@ -1224,6 +1224,56 @@ final class BLEService: NSObject {
return nil
}
// MARK: - Archived public messages ("heard here earlier")
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
guard let sync = gossipSyncManager else {
Task { @MainActor in completion([]) }
return
}
sync.collectPublicMessagePackets { [weak self] packets in
guard let self = self else {
Task { @MainActor in completion([]) }
return
}
// Signature verification and registry lookups run on messageQueue
// like the live receive path.
self.messageQueue.async {
let decoded = packets
.compactMap { self.decodeArchivedPublicMessage($0) }
.sorted { $0.timestamp < $1.timestamp }
Task { @MainActor in completion(decoded) }
}
}
}
private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
guard packet.type == MessageType.message.rawValue,
let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
else { return nil }
let senderPeerID = PeerID(hexData: packet.senderID)
let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
// Archived senders are usually long gone, so the signature-derived
// identity is the best shot at a name; a live registry entry is
// next; anonymous fallback matches the live path.
let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
?? BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: senderPeerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peers,
allowConnectedUnverified: false
)
?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
return ArchivedPublicMessage(
packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
senderPeerID: senderPeerID,
senderNickname: nickname,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
)
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
fileTransferHandler.handle(packet, from: peerID)
}
+6 -5
View File
@@ -22,7 +22,7 @@ final class BoardManager: ObservableObject {
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String?
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String?
/// Requests NIP-09 deletion of a previously bridged note.
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
@@ -34,7 +34,7 @@ final class BoardManager: ObservableObject {
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
) {
self.transport = transport
@@ -126,7 +126,7 @@ final class BoardManager: ObservableObject {
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
bridgedEventIDs[postID] = eventID
}
return true
@@ -158,7 +158,7 @@ final class BoardManager: ObservableObject {
return true
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
@@ -171,7 +171,8 @@ final class BoardManager: ObservableObject {
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
urgent: urgent
)
NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
+8 -1
View File
@@ -23,6 +23,9 @@ struct NoticeItem: Identifiable, Equatable {
let content: String
let createdAt: Date
let isUrgent: Bool
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
/// drops carry). Nil means it only ages out of the relay window.
let expiresAt: Date?
let source: Source
var isBoardPost: Bool {
@@ -36,6 +39,9 @@ struct NoticeItem: Identifiable, Equatable {
content = post.content
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
isUrgent = post.isUrgent
expiresAt = post.expiresAt > 0
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
: nil
source = .board(post)
}
@@ -46,7 +52,8 @@ struct NoticeItem: Identifiable, Equatable {
.first.map(String.init) ?? display
content = note.content
createdAt = note.createdAt
isUrgent = false
isUrgent = note.isUrgent
expiresAt = note.expiresAt
source = .nostr(note)
}
}
+29
View File
@@ -146,6 +146,8 @@ final class CommandProcessor {
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/drop":
return handleDrop(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -171,9 +173,36 @@ final class CommandProcessor {
/ping @name measure round-trip time (mesh only)
/trace @name estimated mesh path (mesh only)
/pay <token> send a cashu ecash token in this chat
/drop <message> pin a note to this place for 24h (needs location)
/help this list
"""
/// /drop <text> a dead drop: pins a note to the current building-level
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
/// looks at notices (or hits the empty-timeline "notes left here" hint)
/// reads it.
private func handleDrop(_ args: String) -> CommandResult {
guard LocationNotesSettings.enabled else {
return .error(message: "location notes are off — enable them in the info screen")
}
guard let content = args.trimmedOrNilIfEmpty else {
return .error(message: "usage: /drop <message>")
}
let location = LocationChannelManager.shared
guard location.permissionState == .authorized else {
return .error(message: "leaving a note needs location — enable it in the info screen")
}
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
location.refreshChannels()
return .error(message: "still finding this place — try again in a moment")
}
guard let nickname = contextProvider?.nickname,
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left")
}
return .success(message: "📍 note left here — it fades in 24h")
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -0,0 +1,122 @@
//
// GeohashChatActivityTracker.swift
// bitchat
//
// Tracks actual chat-message activity per sampled geohash so the empty mesh
// timeline can point at a nearby channel where a conversation is happening
// not merely where participants are present.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// A recent chat message observed in a sampled geohash channel.
struct GeohashChatPreview: Equatable, Sendable {
let senderName: String
let content: String
let timestamp: Date
init(senderName: String, content: String, timestamp: Date) {
self.senderName = senderName
self.content = content
self.timestamp = timestamp
}
}
/// The liveliest nearby conversation, resolved against the user's regional
/// channels.
struct NearbyConversation: Equatable, Sendable {
let channel: GeohashChannel
/// Chat messages seen within the activity window.
let messageCount: Int
let lastMessage: GeohashChatPreview
}
/// Records kind-20000 chat events seen by the background geohash sampling
/// subscriptions (blocked and self senders are filtered by the caller) and
/// answers "where nearby is a conversation actually happening?".
@MainActor
final class GeohashChatActivityTracker: ObservableObject {
static let shared = GeohashChatActivityTracker()
/// How far back a message still counts as "a conversation is happening".
private let window: TimeInterval
/// Per-geohash recent message timestamps (pruned to the window).
private var messageTimes: [String: [Date]] = [:]
/// Per-geohash newest message preview.
private var lastMessages: [String: GeohashChatPreview] = [:]
private let now: () -> Date
init(
window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds,
now: @escaping () -> Date = { Date() }
) {
self.window = window
self.now = now
}
func recordChatMessage(
geohash: String,
senderName: String,
content: String,
timestamp: Date
) {
let gh = geohash.lowercased()
let clamped = min(timestamp, now())
guard now().timeIntervalSince(clamped) < window else { return }
var times = messageTimes[gh] ?? []
times.append(clamped)
messageTimes[gh] = prune(times)
if let existing = lastMessages[gh], existing.timestamp > clamped {
// Keep the newer preview.
} else {
lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped)
}
objectWillChange.send()
}
/// Messages seen in the window for one geohash.
func messageCount(for geohash: String) -> Int {
prune(messageTimes[geohash.lowercased()] ?? []).count
}
func lastMessage(for geohash: String) -> GeohashChatPreview? {
let gh = geohash.lowercased()
guard messageCount(for: gh) > 0 else { return nil }
return lastMessages[gh]
}
/// The busiest channel with at least one chat message in the window.
/// Ties go to the more local (higher-precision) channel, so a lone
/// message on your block beats a lone message across the region.
func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? {
var best: NearbyConversation?
for channel in channels {
let count = messageCount(for: channel.geohash)
guard count > 0, let last = lastMessage(for: channel.geohash) else { continue }
let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last)
if let current = best {
let better = count > current.messageCount
|| (count == current.messageCount
&& channel.level.precision > current.channel.level.precision)
if better { best = candidate }
} else {
best = candidate
}
}
return best
}
func clear() {
messageTimes.removeAll()
lastMessages.removeAll()
objectWillChange.send()
}
private func prune(_ times: [Date]) -> [Date] {
let cutoff = now().addingTimeInterval(-window)
return times.filter { $0 >= cutoff }
}
}
+113 -6
View File
@@ -70,6 +70,30 @@ final class LocationNotesManager: ObservableObject {
/// The matched `g` tag: the cell the note was posted to, which can be
/// a neighbor of the subscribed geohash.
let geohash: String
/// NIP-40 expiration, when the note carries one (dead drops do).
let expiresAt: Date?
/// Carries a `["t","urgent"]` tag (parity with urgent board posts).
let isUrgent: Bool
init(
id: String,
pubkey: String,
content: String,
createdAt: Date,
nickname: String?,
geohash: String,
expiresAt: Date? = nil,
isUrgent: Bool = false
) {
self.id = id
self.pubkey = pubkey
self.content = content
self.createdAt = createdAt
self.nickname = nickname
self.geohash = geohash
self.expiresAt = expiresAt
self.isUrgent = isUrgent
}
var displayName: String {
let suffix = String(pubkey.suffix(4))
@@ -90,6 +114,7 @@ final class LocationNotesManager: ObservableObject {
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private var expiryPruneTimer: Timer?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
@@ -123,6 +148,33 @@ final class LocationNotesManager: ObservableObject {
self.subscribe()
}
}
// NIP-40 notes can expire while displayed (a 24h dead drop crossing
// its boundary); ingest-time filtering alone would keep it visible
// until the subscription is recreated.
expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.pruneExpiredNotes()
}
}
}
deinit {
expiryPruneTimer?.invalidate()
}
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
/// `noteIDs` so a relay replay cannot resurrect them.
func pruneExpiredNotes() {
let now = dependencies.now()
let expired = notes.contains { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
guard expired else { return }
notes.removeAll { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
}
func setGeohash(_ newGeohash: String) {
@@ -202,10 +254,15 @@ final class LocationNotesManager: ObservableObject {
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
})?[1].lowercased() else { return }
guard !self.noteIDs.contains(event.id) else { return }
// NIP-40: relays are not required to enforce expiration drop
// expired notes client-side so 24h dead drops actually vanish.
let expiresAt = Self.expirationDate(of: event)
if let expiresAt, expiresAt <= self.dependencies.now() { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" }
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
@@ -219,8 +276,10 @@ final class LocationNotesManager: ObservableObject {
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
/// Send a location note for the current geohash using the per-geohash
/// identity, optionally expiring via NIP-40 (dead drops pass 24h; the
/// composer's option passes nil) and optionally tagged urgent.
func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
@@ -235,7 +294,9 @@ final class LocationNotesManager: ObservableObject {
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
nickname: nickname,
expiresAt: expiresAt,
urgent: urgent
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
@@ -245,7 +306,9 @@ final class LocationNotesManager: ObservableObject {
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname,
geohash: geohash
geohash: geohash,
expiresAt: expiresAt,
isUrgent: urgent
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
@@ -288,6 +351,14 @@ final class LocationNotesManager: ObservableObject {
}
}
/// The NIP-40 `expiration` tag as a date, if the event carries one.
static func expirationDate(of event: NostrEvent) -> Date? {
guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }),
let seconds = TimeInterval(tag[1])
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
@@ -297,7 +368,43 @@ final class LocationNotesManager: ObservableObject {
}
}
/// Explicitly cancel subscription and release resources.
/// One-shot dead-drop publish without holding a subscription: pins a
/// note to `geohash` that expires via NIP-40. Returns false when no geo
/// relays are known or signing fails.
@MainActor
static func postDrop(
content: String,
nickname: String,
geohash: String,
expiry: TimeInterval = TransportConfig.locationDropExpirySeconds,
dependencies: LocationNotesDependencies = .live
) -> Bool {
guard let trimmed = content.trimmedOrNilIfEmpty else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session)
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: dependencies.now().addingTimeInterval(expiry)
)
dependencies.sendEvent(event, relays)
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session)
return false
}
}
/// Explicitly cancel the subscription. The prune timer stays alive (it
/// holds only a weak self) so a reused instance the notices sheet
/// cancels on tab switch and refreshes on return keeps pruning.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
@@ -0,0 +1,29 @@
//
// LocationNotesSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// User preference for location notes (dead drops): leaving notes pinned to
/// nearby places with /drop and surfacing notes others left here. On by
/// default, but everything it powers additionally requires location
/// permission the toggle in app info is the kill switch.
enum LocationNotesSettings {
private static let enabledKey = "locationNotes.enabled"
/// Fired on every toggle write so live consumers (the nearby-notes
/// counter) can drop or restart their relay subscription immediately.
static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange")
static var enabled: Bool {
get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: enabledKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
//
// MeshEchoSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Watermark for "heard here earlier" echoes: clearing the mesh timeline
/// (triple-tap or /clear) records the moment, and the next launch only
/// re-seeds archived messages heard after it. The archive itself is left
/// alone the device keeps carrying those messages for peers; the user
/// just doesn't want to see them again.
enum MeshEchoSettings {
private static let clearedThroughKey = "meshEchoes.clearedThrough"
static var clearedThrough: Date? {
get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date }
set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) }
}
static func reset() {
UserDefaults.standard.removeObject(forKey: clearedThroughKey)
}
}
+106
View File
@@ -0,0 +1,106 @@
//
// MeshSightingsTracker.swift
// bitchat
//
// Privacy-preserving daily tally of mesh peers that came within radio range,
// so an empty timeline can say "3 devices passed within range today" instead
// of feeling dead. Stores only a per-day salted hash per peer plus a count
// no identities, no history beyond today.
// This is free and unencumbered software released into the public domain.
//
import BitFoundation
import CryptoKit
import Foundation
@MainActor
final class MeshSightingsTracker: ObservableObject {
static let shared = MeshSightingsTracker()
private enum Keys {
static let dayKey = "meshSightings.dayKey"
static let salt = "meshSightings.salt"
static let hashes = "meshSightings.hashes"
static let lastSeenAt = "meshSightings.lastSeenAt"
}
/// Distinct devices seen within range today (rotating peer IDs may count
/// a long-lived neighbor more than once across rotations; that is fine
/// for an ambient stat).
@Published private(set) var todayCount: Int = 0
@Published private(set) var lastSightingAt: Date?
private let defaults: UserDefaults
private let now: () -> Date
private var seenHashes: Set<String> = []
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) {
self.defaults = defaults
self.now = now
restore()
}
func recordSighting(peerID: PeerID) {
rollOverIfNeeded()
let hash = saltedHash(peerID.id)
let seenAt = now()
lastSightingAt = seenAt
defaults.set(seenAt, forKey: Keys.lastSeenAt)
guard seenHashes.insert(hash).inserted else { return }
todayCount = seenHashes.count
defaults.set(Array(seenHashes), forKey: Keys.hashes)
}
func clear() {
seenHashes.removeAll()
todayCount = 0
lastSightingAt = nil
defaults.removeObject(forKey: Keys.dayKey)
defaults.removeObject(forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes)
defaults.removeObject(forKey: Keys.lastSeenAt)
}
private func restore() {
rollOverIfNeeded()
seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? [])
todayCount = seenHashes.count
lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date
}
/// Resets the tally when the local calendar day changes; the salt rotates
/// with it so hashes from different days can never be correlated.
private func rollOverIfNeeded() {
let today = Self.dayKey(for: now())
guard defaults.string(forKey: Keys.dayKey) != today else { return }
defaults.set(today, forKey: Keys.dayKey)
defaults.set(Self.randomSalt(), forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes)
seenHashes.removeAll()
todayCount = 0
}
private func saltedHash(_ value: String) -> String {
let salt = defaults.data(forKey: Keys.salt) ?? {
let fresh = Self.randomSalt()
defaults.set(fresh, forKey: Keys.salt)
return fresh
}()
var digest = SHA256()
digest.update(data: salt)
digest.update(data: Data(value.utf8))
return digest.finalize().map { String(format: "%02x", $0) }.joined()
}
private static func dayKey(for date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar.current
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private static func randomSalt() -> Data {
Data((0..<16).map { _ in UInt8.random(in: .min ... .max) })
}
}
+53 -3
View File
@@ -26,6 +26,10 @@ protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
protocol NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
@@ -55,6 +59,18 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque
}
}
private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func setCategories(_ categories: Set<UNNotificationCategory>) {
center.setNotificationCategories(categories)
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
@@ -68,12 +84,21 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>) {}
}
final class NotificationService {
static let shared = NotificationService()
/// Category for the "bitchatters nearby" notification, carrying the wave quick action.
static let nearbyCategoryID = "chat.bitchat.category.nearby"
static let waveActionID = "chat.bitchat.action.wave"
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
private let categoryRegistrar: NotificationCategoryRegistering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
@@ -92,25 +117,30 @@ final class NotificationService {
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
self.categoryRegistrar = NoopNotificationCategoryRegistrar()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
requestDeliverer: NotificationRequestDelivering,
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar()
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
self.categoryRegistrar = categoryRegistrar
}
func requestAuthorization() {
guard !isRunningTests else { return }
registerCategories()
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
// Permission granted
@@ -119,13 +149,29 @@ final class NotificationService {
}
}
}
private func registerCategories() {
let wave = UNNotificationAction(
identifier: Self.waveActionID,
title: "wave 👋",
options: []
)
let nearby = UNNotificationCategory(
identifier: Self.nearbyCategoryID,
actions: [wave],
intentIdentifiers: [],
options: []
)
categoryRegistrar.setCategories([nearby])
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
interruptionLevel: UNNotificationInterruptionLevel = .active,
categoryIdentifier: String? = nil
) {
guard !isRunningTests else { return }
let content = UNMutableNotificationContent()
@@ -133,6 +179,9 @@ final class NotificationService {
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let categoryIdentifier = categoryIdentifier {
content.categoryIdentifier = categoryIdentifier
}
if let userInfo = userInfo {
content.userInfo = userInfo
@@ -183,7 +232,8 @@ final class NotificationService {
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive
interruptionLevel: .timeSensitive,
categoryIdentifier: Self.nearbyCategoryID
)
}
}
+30
View File
@@ -211,6 +211,32 @@ protocol Transport: AnyObject {
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
// Store-and-forward archive (mesh transports only): the public messages
// this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
}
/// A carried public mesh message from the store-and-forward window, decoded
/// for display. `packetIdHex` is stable across launches so echo rows keep a
/// deterministic message ID.
struct ArchivedPublicMessage {
let packetIdHex: String
let senderPeerID: PeerID
let senderNickname: String
let content: String
let timestamp: Date
}
extension BitchatMessage {
/// Echo rows are minted locally with this prefix (packet-id derived, so
/// stable across launches); the timeline dims them.
static let archivedEchoIDPrefix = "echo-"
var isArchivedEcho: Bool {
id.hasPrefix(Self.archivedEchoIDPrefix)
}
}
extension Transport {
@@ -261,6 +287,10 @@ extension Transport {
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) }
}
}
protocol TransportPeerEventsDelegate: AnyObject {
+8
View File
@@ -172,6 +172,14 @@ enum TransportConfig {
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
// Startup delay before reading the gossip archive for "heard here
// earlier" echoes; covers the archive's async disk restore.
static let uiArchivedEchoLoadDelaySeconds: TimeInterval = 1.5
// Dead drops: location notes left via /drop expire after this long.
static let locationDropExpirySeconds: TimeInterval = 24 * 60 * 60
// Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300
+13
View File
@@ -652,6 +652,19 @@ final class GossipSyncManager {
}
}
/// Snapshot of the carried public-message packets (fresh window only),
/// for the "heard here earlier" timeline echoes. Completion runs on the
/// sync queue.
func collectPublicMessagePackets(completion: @escaping ([BitchatPacket]) -> Void) {
queue.async { [weak self] in
guard let self else {
completion([])
return
}
completion(self.messages.allPackets(isFresh: self.isPacketFresh))
}
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
@@ -112,6 +112,13 @@ final class ChatOutgoingCoordinator {
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
}
}
/// Broadcasts a wave on the mesh channel regardless of the active channel
/// used by the "bitchatters nearby" notification quick action, which always
/// refers to mesh peers.
func sendMeshWave() {
sendMeshPublicMessage(originalContent: "👋", trimmed: "👋", mentions: [])
}
}
private extension ChatOutgoingCoordinator {
@@ -36,6 +36,9 @@ protocol ChatPeerListContext: AnyObject {
// MARK: Notifications
/// Posts the "bitchatters nearby" local notification.
func notifyNetworkAvailable(peerCount: Int)
/// Records peers seen within range for the daily ambient sightings tally.
func recordMeshSightings(peerIDs: [PeerID])
}
extension ChatViewModel: ChatPeerListContext {
@@ -60,6 +63,12 @@ extension ChatViewModel: ChatPeerListContext {
func notifyNetworkAvailable(peerCount: Int) {
NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount)
}
func recordMeshSightings(peerIDs: [PeerID]) {
for peerID in peerIDs {
MeshSightingsTracker.shared.recordSighting(peerID: peerID)
}
}
}
final class ChatPeerListCoordinator: @unchecked Sendable {
@@ -129,6 +138,7 @@ private extension ChatPeerListCoordinator {
}
invalidateNetworkEmptyTimer()
context.recordMeshSightings(peerIDs: meshPeers)
let newPeers = meshPeerSet.subtracting(recentlySeenPeers)
// Record every sighted peer even when no notification fires. A peer
@@ -288,6 +288,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func clearCurrentPublicTimeline() {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
// Clearing the mesh timeline also dismisses its archived echoes for
// good: the watermark stops the next launch from re-seeding them
// (the archive itself keeps carrying the messages for peers), and
// the dedup keys go so a cleared message arriving live shows again.
if case .mesh = context.activeChannel {
MeshEchoSettings.clearedThrough = Date()
archivedEchoKeys.removeAll()
}
// The SPM test process shares the real Application Support tree, so this
// detached deletion can land mid-test under parallel scheduling and flake
// a file-dependent test. Tests never need the on-disk media cleared.
@@ -407,6 +416,21 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
/// rate limit; low/no-PoW events keep the strict limits so old clients
/// still get through at normal rates.
/// Identity keys of the archived echoes seeded into the mesh timeline at
/// launch. The mesh wire format carries no stable message ID, so a
/// re-synced copy of an already-rendered echo arrives with a fresh UUID
/// this content identity is the only way to recognize it.
private var archivedEchoKeys = Set<String>()
func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) {
archivedEchoKeys.insert(Self.archivedEchoKey(senderPeerID: senderPeerID, timestamp: timestamp, content: content))
}
static func archivedEchoKey(senderPeerID: PeerID?, timestamp: Date, content: String) -> String {
let ms = UInt64((timestamp.timeIntervalSince1970 * 1000).rounded())
return "\(senderPeerID?.id ?? "")|\(ms)|\(content)"
}
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
let finalMessage = context.processActionMessage(message)
if context.isMessageBlocked(finalMessage) { return }
@@ -442,6 +466,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
guard let destination else { return }
// A live copy of a message already rendered as an archived echo
// (e.g. re-served by a peer's gossip sync) would duplicate the row.
if destination == .mesh, !isSystem {
let key = Self.archivedEchoKey(
senderPeerID: finalMessage.senderPeerID,
timestamp: finalMessage.timestamp,
content: finalMessage.content
)
if archivedEchoKeys.contains(key) { return }
}
let channelMatches: Bool = {
switch context.activeChannel {
case .mesh: return !isGeo || isSystem
+12
View File
@@ -904,6 +904,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
outgoingCoordinator.sendMessage(content)
}
/// Sends a 👋 to the mesh channel regardless of the active channel.
@MainActor
func sendMeshWave() {
outgoingCoordinator.sendMeshWave()
}
// MARK: - Geohash Participants
@MainActor
@@ -1169,6 +1175,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset()
// Ambient-liveliness bookkeeping: sampled nearby-chat previews, the
// daily sightings tally, and the echoes-dismissed watermark
GeohashChatActivityTracker.shared.clear()
MeshSightingsTracker.shared.clear()
MeshEchoSettings.reset()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Drop cached peers' prekey bundles (who we could write to is
@@ -178,6 +178,8 @@ private extension ChatViewModelBootstrapper {
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
loadArchivedEchoes()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
let bleService = viewModel.meshService as? BLEService else { return }
@@ -197,6 +199,58 @@ private extension ChatViewModelBootstrapper {
}
}
/// Surfaces the carried store-and-forward window (up to 6h of public
/// mesh messages, persisted across restarts) as dimmed "heard here
/// earlier" rows, so the mesh timeline opens with the place's memory
/// instead of a void. The archive restore runs async on the sync queue
/// right after transport start, so give it a beat before asking.
private func loadArchivedEchoes() {
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in
// A previous /clear dismissed everything heard up to its
// watermark; only newer archive entries come back.
let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast
let archived = allArchived.filter { $0.timestamp > clearedThrough }
guard let viewModel, !archived.isEmpty else { return }
// Seed only an untouched timeline: with live rows already
// present (or after /clear) splicing history back in would
// be wrong.
guard viewModel.conversations.conversationsByID[.mesh]?.messages.isEmpty != false else { return }
for item in archived {
let echo = BitchatMessage(
id: BitchatMessage.archivedEchoIDPrefix + item.packetIdHex,
sender: item.senderNickname,
content: item.content,
timestamp: item.timestamp,
isRelay: false,
senderPeerID: item.senderPeerID
)
viewModel.publicConversationCoordinator.registerArchivedEcho(
senderPeerID: item.senderPeerID,
timestamp: item.timestamp,
content: item.content
)
_ = viewModel.appendPublicMessage(echo, to: .mesh)
}
if let firstTimestamp = archived.map(\.timestamp).min() {
// Echo-prefixed ID so the divider joins the tinted,
// dimmed echo block in the timeline.
let divider = BitchatMessage(
id: BitchatMessage.archivedEchoIDPrefix + "divider",
sender: "system",
content: String(localized: "content.echoes.divider", comment: "System line shown above dimmed archived messages replayed on the mesh timeline at launch"),
timestamp: firstTimestamp.addingTimeInterval(-1),
isRelay: false
)
_ = viewModel.appendPublicMessage(divider, to: .mesh)
}
}
}
}
func bindPeerService() {
viewModel.unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
@@ -115,6 +115,16 @@ final class GeoPresenceTracker {
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
}
// Non-empty content on a sampled event means an actual chat message
// (presence events are empty) feed the nearby-conversation hint.
GeohashChatActivityTracker.shared.recordChatMessage(
geohash: gh,
senderName: Self.sampledSenderName(for: event, context: context),
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
)
guard existingCount == 0 else { return }
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -131,6 +141,19 @@ final class GeoPresenceTracker {
cooldownPerGeohash(gh, content: content, event: event)
}
/// Attribution for a sampled event: the event's own `n` tag wins (the
/// active-channel nickname table only covers the selected geohash),
/// falling back to the table, then "anon", always suffixed with the
/// pubkey tail like every other geohash display name.
@MainActor
static func sampledSenderName(for event: NostrEvent, context: any GeoPresenceContext) -> String {
let suffix = String(event.pubkey.suffix(4))
let tagNick = event.tags.first { $0.count >= 2 && $0[0].lowercased() == "n" }?[1]
let nick = tagNick?.trimmedOrNilIfEmpty
?? context.geoNicknames[event.pubkey.lowercased()]?.trimmedOrNilIfEmpty
return (nick ?? "anon") + "#" + suffix
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
guard let context else { return }
+52 -6
View File
@@ -10,6 +10,8 @@ struct AppInfoView: View {
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
@State private var locationNotesEnabled = LocationNotesSettings.enabled
@ObservedObject private var locationManager = LocationChannelManager.shared
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
@@ -88,6 +90,17 @@ struct AppInfoView: View {
)
}
enum Location {
static let title: LocalizedStringKey = "app_info.location.title"
static let notes = AppInfoFeatureInfo(
icon: "mappin.and.ellipse",
title: "app_info.location.notes.title",
description: "app_info.location.notes.description"
)
static let enable: LocalizedStringKey = "app_info.location.enable"
static let openSettings: LocalizedStringKey = "app_info.location.open_settings"
}
enum Network {
static let title: LocalizedStringKey = "app_info.network.title"
static let topology = AppInfoFeatureInfo(
@@ -250,19 +263,24 @@ struct AppInfoView: View {
}
}
// Voice
// Location (notes / dead drops)
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Voice.title)
SectionHeader(Strings.Location.title)
HStack(spacing: 0) {
FeatureRow(info: Strings.Voice.live)
Toggle(Strings.Voice.live.title, isOn: $liveVoiceEnabled)
FeatureRow(info: Strings.Location.notes)
Toggle(Strings.Location.notes.title, isOn: $locationNotesEnabled)
.labelsHidden()
.tint(palette.accent)
.onChange(of: liveVoiceEnabled) { newValue in
PTTSettings.liveVoiceEnabled = newValue
.onChange(of: locationNotesEnabled) { newValue in
LocationNotesSettings.enabled = newValue
if newValue {
locationManager.enableLocationChannels()
}
}
}
locationPermissionRow
}
// Network diagnostics
@@ -340,6 +358,34 @@ struct AppInfoView: View {
}
}
private extension AppInfoView {
/// One status/action line under the notes toggle so the location
/// requirement is actionable right here instead of only in the channel
/// sheet.
@ViewBuilder
var locationPermissionRow: some View {
if locationNotesEnabled {
switch locationManager.permissionState {
case .notDetermined:
Button(Strings.Location.enable) {
locationManager.enableLocationChannels()
}
.buttonStyle(.plain)
.bitchatFont(size: 13)
.foregroundColor(palette.accent)
case .denied, .restricted:
Button(Strings.Location.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
.bitchatFont(size: 13)
.foregroundColor(palette.accent)
case .authorized:
// Granted needs no status line the toggle being on says it.
EmptyView()
}
}
}
}
struct AppInfoFeatureInfo {
let icon: String
let title: LocalizedStringKey
@@ -0,0 +1,180 @@
//
// MeshEmptyStateView.swift
// bitchat
//
// The empty mesh timeline, upgraded from a dead end into a live surface:
// a sonar shows the radio scanning, the daily sightings tally proves the
// spot isn't dead, the liveliest nearby geohash conversation is one tap
// away, and notes left at this place surface when there are any.
// This is free and unencumbered software released into the public domain.
//
import SwiftUI
struct MeshEmptyStateView: View {
/// Visible chat height to fill; the radar centers in the space left
/// below the narration. Zero (previews) keeps a compact layout.
var fillHeight: CGFloat = 0
/// Ambient-footer mode, appended below archived echoes: skips the
/// intro/help narration (the timeline isn't empty) and shrinks the
/// radar, keeping the sightings tally and the live hints visible.
var compact: Bool = false
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@ObservedObject private var activityTracker = GeohashChatActivityTracker.shared
@ObservedObject private var sightingsTracker = MeshSightingsTracker.shared
@ThemedPalette private var palette
/// The activity window is evaluated at render time; without new events
/// nothing would trigger a re-render, so a stale "people are talking"
/// hint could linger. A slow tick keeps the hints and relative times
/// honest.
@State private var refreshTick = 0
private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
private enum Strings {
static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")
static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")
static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today")
static func sightingsMany(_ count: Int) -> String {
String(
format: String(localized: "content.empty.sightings_many", comment: "Empty mesh timeline stat counting devices that came within range today"),
locale: .current,
count
)
}
static func activityOne(_ geohash: String) -> String {
String(
format: String(localized: "content.empty.activity_one", comment: "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash"),
locale: .current,
geohash
)
}
static func activityMany(_ geohash: String) -> String {
String(
format: String(localized: "content.empty.activity_many", comment: "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash"),
locale: .current,
geohash
)
}
}
/// The radar means "searching for people": once anyone is connected or
/// reachable on the mesh, the search is over and the sweep goes away.
private var isSearchingForPeers: Bool {
peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if compact {
if isSearchingForPeers {
radarBlock
}
if let conversation = nearbyConversation {
conversationHint(conversation)
}
} else {
// The radar + tally already say "scanning, nobody yet", so
// the narration stays to two lines with the live hint after
// them, not wedged in between.
narrationLine(Strings.meshIntro)
narrationLine(Strings.switchHint)
if let conversation = nearbyConversation {
conversationHint(conversation)
}
// The radar centers in whatever space is left below the
// text the flexible spacers split it evenly.
if isSearchingForPeers {
Spacer(minLength: 24)
radarBlock
Spacer(minLength: 12)
}
}
}
.frame(minHeight: compact ? 0 : fillHeight, alignment: .top)
.onReceive(refreshTimer) { _ in refreshTick += 1 }
}
/// The radar with today's tally as its caption the stat belongs to
/// the scanning visual, not the narration lines.
private var radarBlock: some View {
VStack(spacing: 4) {
MeshRadarView(height: compact ? 44 : 72)
if sightingsTracker.todayCount > 0 {
Text(verbatim: sightingsText)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary.opacity(0.8))
}
}
.frame(maxWidth: .infinity)
}
}
private extension MeshEmptyStateView {
var nearbyConversation: NearbyConversation? {
activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels)
}
var sightingsText: String {
sightingsTracker.todayCount == 1
? Strings.sightingsOne
: Strings.sightingsMany(sightingsTracker.todayCount)
}
func conversationHint(_ conversation: NearbyConversation) -> some View {
let headline = conversation.messageCount == 1
? Strings.activityOne(conversation.channel.geohash)
: Strings.activityMany(conversation.channel.geohash)
return Button {
locationChannelsModel.markTeleported(for: conversation.channel.geohash, false)
locationChannelsModel.select(.location(conversation.channel))
} label: {
VStack(alignment: .leading, spacing: 2) {
actionLine("💬 \(headline)")
narrationLine(" \(previewText(for: conversation.lastMessage))")
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
func previewText(for message: GeohashChatPreview) -> String {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
var content = message.content
if content.count > maxLen {
content = String(content.prefix(maxLen)) + ""
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
let ago = formatter.localizedString(for: message.timestamp, relativeTo: Date())
return "<\(message.senderName)> \(content) · \(ago)"
}
func narrationLine(_ text: String) -> some View {
emptyStateLine(text, color: palette.secondary.opacity(0.9))
}
/// Tappable lines render in the primary color so they read as actions
/// amid the grey narration.
func actionLine(_ text: String) -> some View {
emptyStateLine(text, color: palette.primary)
}
func emptyStateLine(_ text: String, color: Color) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
Text(verbatim: "* \(text)\u{00A0}*")
.bitchatFont(size: 13)
.foregroundColor(color)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -0,0 +1,68 @@
//
// MeshRadarView.swift
// bitchat
//
// Ambient sonar shown on the empty mesh timeline: expanding rings around a
// center dot make it visible that the radio is broadcasting and scanning
// even when nobody is in range. Purely decorative hidden from
// accessibility, static under Reduce Motion.
// This is free and unencumbered software released into the public domain.
//
import SwiftUI
struct MeshRadarView: View {
/// Full size on the empty timeline; the ambient footer under archived
/// echoes uses a smaller one.
var height: CGFloat = 72
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ThemedPalette private var palette
private let ringCount = 3
private let period: TimeInterval = 3.0
var body: some View {
Group {
if reduceMotion {
radar(at: 0.35)
} else {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { context in
radar(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.frame(height: height)
.frame(maxWidth: .infinity)
.accessibilityHidden(true)
}
private func radar(at time: TimeInterval) -> some View {
Canvas { context, size in
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let maxRadius = min(size.width, size.height) / 2 - 2
for ring in 0..<ringCount {
let phase = (time / period + Double(ring) / Double(ringCount))
.truncatingRemainder(dividingBy: 1)
let radius = maxRadius * phase
guard radius > 1 else { continue }
let alpha = 0.45 * (1 - phase)
let rect = CGRect(
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
)
context.stroke(
Path(ellipseIn: rect),
with: .color(palette.primary.opacity(alpha)),
lineWidth: 1
)
}
let dot = CGRect(x: center.x - 2, y: center.y - 2, width: 4, height: 4)
context.fill(Path(ellipseIn: dot), with: .color(palette.primary.opacity(0.9)))
}
}
}
+21 -8
View File
@@ -23,12 +23,15 @@ struct ContentHeaderView: View {
/// Unified notices sheet (board posts + location notes) for the current
/// channel context.
@State private var showNotices = false
/// Board posts mirrored from the store so the pin icon can show when the
/// current scope has notices.
@State private var boardPosts: [BoardPostPacket] = []
/// Nostr-only location notes at this place (live while the empty mesh
/// timeline is showing) they should light the pin too.
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -157,11 +160,11 @@ struct ContentHeaderView: View {
scopes.insert(geoScope)
}
boardAlertsModel.markSeen(forScopes: scopes)
showNotices = true
appChromeModel.presentNotices()
}) {
// Fill marks unseen new pins; the tint says the current
// scope has notices at all.
Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
// Filled whenever the current scope has notices at all
// (matching the orange tint); hollow means nothing here.
Image(systemName: scopeHasNotices || unseenNoticesCount > 0 ? "pin.fill" : "pin")
.font(.bitchatSystem(size: 12))
.foregroundColor(
scopeHasNotices || unseenNoticesCount > 0
@@ -293,7 +296,10 @@ struct ContentHeaderView: View {
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
.sheet(isPresented: $showNotices) {
.sheet(
isPresented: $appChromeModel.isNoticesSheetPresented,
onDismiss: { appChromeModel.noticesSheetPrefersGeoTab = false }
) {
NoticesView(
senderNickname: appChromeModel.nickname,
board: appChromeModel.boardManager,
@@ -334,8 +340,12 @@ private extension ContentHeaderView {
}
/// Open the notices sheet on the tab matching the current channel: the
/// geohash channel's notices, or the mesh-local board in mesh chat.
/// geohash channel's notices, or the mesh-local board in mesh chat. An
/// explicit geo-tab request (the "notes left here" hint) wins.
var initialNoticesTab: NoticesView.Tab {
if appChromeModel.noticesSheetPrefersGeoTab {
return .geo
}
if case .location = locationChannelsModel.selectedChannel {
return .geo
}
@@ -351,9 +361,12 @@ private extension ContentHeaderView {
return locationChannelsModel.currentBuildingGeohash
}
/// Whether either tab of the notices sheet currently has content.
/// Whether either tab of the notices sheet currently has content: board
/// posts in scope, plus Nostr-only location notes when the nearby-notes
/// counter happens to be live (it runs with the empty mesh timeline).
var scopeHasNotices: Bool {
boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope }
|| nearbyNotes.noteCount > 0
}
/// New pins in either visible scope since the sheet was last opened.
+100 -5
View File
@@ -19,6 +19,8 @@ struct MessageListView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var appChromeModel: AppChromeModel
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@@ -45,6 +47,9 @@ struct MessageListView: View {
/// switches swap the timeline wholesale, so a count delta is only a
/// "new messages" signal while the context is unchanged.
@State private var unseenBaselineKey = ""
/// Whether this instance holds the nearby-notes counter active (mesh
/// public timeline only); balanced against activate/deactivate.
@State private var holdsNotesCounter = false
@ThemedPalette private var palette
@@ -72,10 +77,19 @@ struct MessageListView: View {
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
VStack(spacing: 0) {
// Notes pinned to this place stay visible while chatting a
// conversation starting must not hide what's left here.
if privatePeer == nil,
case .mesh = locationChannelsModel.selectedChannel,
nearbyNotes.noteCount > 0 {
notesHereStrip
}
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
publicEmptyState(fillHeight: geometry.size.height)
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
@@ -150,10 +164,23 @@ struct MessageListView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
// Archived echoes read as one tinted block, not
// just faded rows.
.background(message.isArchivedEcho ? palette.secondary.opacity(0.08) : Color.clear)
}
}
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
// Only carried history on screen: the ambient layer (radar,
// sightings, live hints) stays visible below it instead of
// vanishing the moment echoes exist.
if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) {
MeshEmptyStateView(compact: true)
.padding(.horizontal, 12)
.padding(.top, 20)
.padding(.bottom, 8)
}
}
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
@@ -246,6 +273,12 @@ struct MessageListView: View {
scrollThrottleTimer?.invalidate()
}
}
}
}
.onAppear { updateNotesCounterHold() }
.onDisappear { releaseNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() }
.onChange(of: privatePeer) { _ in updateNotesCounterHold() }
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
@@ -270,16 +303,75 @@ private extension MessageListView {
return locationChannelsModel.selectedChannel.contextKey
}
/// Tappable strip above the mesh timeline while notes are pinned at this
/// place: opens the notices sheet on the geo tab.
var notesHereStrip: some View {
let text: String = nearbyNotes.noteCount == 1
? String(localized: "content.empty.notes_one", comment: "Hint when exactly one note was left at this place")
: String(
format: String(localized: "content.empty.notes_many", comment: "Hint counting notes left at this place"),
locale: .current,
nearbyNotes.noteCount
)
return Button {
appChromeModel.presentNotices(geoTab: true)
} label: {
HStack(spacing: 6) {
Text(verbatim: "📍 \(text)")
.bitchatFont(size: 12)
.foregroundColor(palette.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(palette.secondary.opacity(0.08))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// The nearby-notes counter runs whenever the mesh public timeline is
/// showing the strip needs a live count before it can decide to exist.
func updateNotesCounterHold() {
let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh
guard shouldHold != holdsNotesCounter else { return }
holdsNotesCounter = shouldHold
if shouldHold {
NearbyNotesCounter.shared.activate()
} else {
NearbyNotesCounter.shared.deactivate()
}
}
func releaseNotesCounterHold() {
guard holdsNotesCounter else { return }
holdsNotesCounter = false
NearbyNotesCounter.shared.deactivate()
}
/// True when the mesh timeline holds nothing but archived echoes and
/// system lines no live conversation yet, so the ambient layer still
/// applies.
private func showsAmbientFooter(messageItems: [MessageDisplayItem]) -> Bool {
guard case .mesh = locationChannelsModel.selectedChannel,
!messageItems.isEmpty else { return false }
return messageItems.allSatisfy { $0.message.isArchivedEcho || $0.message.sender == "system" }
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
var publicEmptyState: some View {
/// The mesh case fills the visible chat height so its radar can center
/// in the space below the text.
func publicEmptyState(fillHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
MeshEmptyStateView(fillHeight: max(0, fillHeight - 24))
case .location(let channel):
emptyStateLine(
String(
@@ -410,6 +502,9 @@ private extension MessageListView {
TextMessageView(message: message)
}
}
// Archived echoes ("heard here earlier") render dimmed: real history,
// visually distinct from the live conversation.
.opacity(message.isArchivedEcho ? 0.55 : 1)
}
@ViewBuilder
+118 -31
View File
@@ -31,10 +31,18 @@ struct NoticesView: View {
@State private var tab: Tab
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
/// Days until the notice fades; `permanentExpiry` (geo default) means no
/// NIP-40 tag the note stays until its relay drops it.
@State private var expiryDays: Int
/// Sentinel picker tag for the option (geo tab only).
private static let permanentExpiry = 0
/// Injected notes manager for tests; live use derives one per geohash.
private let notesManager: LocationNotesManager?
/// Live manager owned by the sheet so the composer can post pure Nostr
/// notes ( expiry has no mesh-board copy) and the list can render them.
@State private var liveGeoManager: LocationNotesManager?
init(
senderNickname: String,
@@ -46,6 +54,27 @@ struct NoticesView: View {
self.board = board
self.notesManager = notesManager
_tab = State(initialValue: initialTab)
_expiryDays = State(initialValue: initialTab == .geo ? Self.permanentExpiry : 7)
}
private var activeNotesManager: LocationNotesManager? {
notesManager ?? liveGeoManager
}
/// Creates (or retargets/revives) the sheet-owned notes manager for the
/// current geo scope.
private func ensureGeoNotesManager() {
guard notesManager == nil, tab == .geo, let geohash = geoGeohash else { return }
if let manager = liveGeoManager {
if manager.geohash != geohash.lowercased() {
manager.setGeohash(geohash)
} else if manager.state == .idle {
// Cancelled on a tab switch; returning re-subscribes.
manager.refresh()
}
} else {
liveGeoManager = LocationNotesManager(geohash: geohash)
}
}
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
@@ -90,6 +119,7 @@ struct NoticesView: View {
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
static let permanentOption = String(localized: "notices.expiry.permanent", defaultValue: "permanent", comment: "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker")
static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
@@ -109,6 +139,16 @@ struct NoticesView: View {
)
}
static func fades(_ expiresAt: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return String(
format: String(localized: "notices.fades", defaultValue: "fades %@", comment: "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'"),
locale: .current,
formatter.localizedString(for: expiresAt, relativeTo: Date())
)
}
static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
let base = String(
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
@@ -132,18 +172,29 @@ struct NoticesView: View {
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
.onAppear { beginGeoLocationIfNeeded() }
.onAppear {
beginGeoLocationIfNeeded()
ensureGeoNotesManager()
}
.onChange(of: tab) { newTab in
if newTab == .geo {
beginGeoLocationIfNeeded()
ensureGeoNotesManager()
} else {
locationChannelsModel.endLiveRefresh()
}
// Each tab keeps its natural default: geo notes stay until
// deleted (), mesh board posts fade within a week.
expiryDays = newTab == .geo ? Self.permanentExpiry : 7
urgent = false
}
// Catches permission granted from the geo tab's enable button.
.onChange(of: locationChannelsModel.permissionState) { _ in
beginGeoLocationIfNeeded()
}
.onChange(of: geoGeohash) { _ in
ensureGeoNotesManager()
}
.onDisappear { locationChannelsModel.endLiveRefresh() }
}
@@ -207,7 +258,14 @@ struct NoticesView: View {
)
case .geo:
if let geohash = geoGeohash {
GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
if let manager = activeNotesManager {
GeoNoticesList(geohash: geohash, board: board, manager: manager)
} else {
// Manager is created on appear; visible for one frame.
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { ensureGeoNotesManager() }
}
} else {
locationUnavailableSection
}
@@ -252,11 +310,10 @@ struct NoticesView: View {
.disabled(!sendEnabled)
.accessibilityLabel(Strings.send)
}
// Urgency and expiry only travel with the mesh copy the bridged
// Nostr note carries neither, so relay-side readers would never
// see them. Offer the controls only where they fully apply.
if tab == .mesh {
HStack(spacing: 12) {
// Both tabs pick an expiry (geo notes may be ); urgency is a
// mesh-board concept notes are ambient by nature.
HStack(spacing: 12) {
if tab == .mesh {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
@@ -265,19 +322,30 @@ struct NoticesView: View {
.toggleStyle(.switch)
.fixedSize()
.accessibilityLabel(Strings.urgentToggle)
Spacer()
Text(Strings.expiryLabel)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Picker(Strings.expiryLabel, selection: $expiryDays) {
ForEach([1, 3, 7], id: \.self) { days in
Text(Strings.expiryDaysOption(days)).tag(days)
}
}
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel(Strings.expiryLabel)
}
Spacer()
Text(Strings.expiryLabel)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Picker(Strings.expiryLabel, selection: $expiryDays) {
// Mesh board posts must fade (the wire caps their
// lifetime); only relay-backed geo notes can be .
if tab == .geo {
Text(verbatim: "")
.accessibilityLabel(Strings.permanentOption)
.tag(Self.permanentExpiry)
}
ForEach([1, 3, 7], id: \.self) { days in
Text(Strings.expiryDaysOption(days)).tag(days)
}
}
.pickerStyle(.segmented)
// macOS segmented pickers render their own label; the themed
// Text alongside already carries it (and accessibility keeps
// the explicit label below).
.labelsHidden()
.fixedSize()
.accessibilityLabel(Strings.expiryLabel)
}
}
.padding(.horizontal, 16)
@@ -293,14 +361,27 @@ struct NoticesView: View {
private func send() {
guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
// Geo posts go to the board and are bridged to Nostr by BoardManager,
// so mesh and internet see the same notice. They always use the
// defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy).
// (geo default): a pure relay note with no NIP-40 tag. It skips
// the mesh board deliberately a board copy must fade within days,
// which would contradict the permanence the user just picked.
if tab == .geo, expiryDays == Self.permanentExpiry {
guard let manager = activeNotesManager else { return }
manager.send(content: content, nickname: senderNickname, expiresAt: nil)
draft = ""
urgent = false
return
}
// Expiring posts go to the board and are bridged to Nostr by
// BoardManager, so mesh and internet see the same notice with the
// chosen expiry (expiresAt on mesh, NIP-40 on the bridged note).
// Urgency is mesh-only.
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: tab == .mesh && urgent,
expiryDays: tab == .mesh ? expiryDays : 7,
expiryDays: expiryDays,
nickname: senderNickname
)
if sent {
@@ -310,18 +391,19 @@ struct NoticesView: View {
}
}
/// The geo tab's list: owns the Nostr notes subscription for the scope
/// geohash and merges it with the board posts for the same geohash.
/// The geo tab's list: renders the sheet-owned Nostr notes subscription
/// merged with the board posts for the same geohash. The manager lives on
/// `NoticesView` so the composer can post through the same instance (
/// notes local-echo into this list).
private struct GeoNoticesList: View {
let geohash: String
@ObservedObject var board: BoardManager
@StateObject private var notesManager: LocationNotesManager
@ObservedObject var notesManager: LocationNotesManager
init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
let gh = geohash.lowercased()
self.geohash = gh
init(geohash: String, board: BoardManager, manager: LocationNotesManager) {
self.geohash = geohash.lowercased()
self.board = board
_notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
self.notesManager = manager
}
var body: some View {
@@ -475,6 +557,11 @@ private struct NoticesList: View {
Text(Self.timestampText(for: item.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
if let expiresAt = item.expiresAt, expiresAt > Date() {
Text(Strings.fades(expiresAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary.opacity(0.8))
}
Spacer()
if showsSource {
sourceBadge(item)
@@ -68,6 +68,13 @@ private final class MockChatPeerListContext: ChatPeerListContext {
func notifyNetworkAvailable(peerCount: Int) {
networkAvailableNotifications.append(peerCount)
}
// Sightings
private(set) var recordedSightings: [[PeerID]] = []
func recordMeshSightings(peerIDs: [PeerID]) {
recordedSightings.append(peerIDs)
}
}
// MARK: - Helpers
@@ -216,6 +216,126 @@ struct LocationNotesManagerTests {
#expect(manager.errorMessage == nil)
}
@Test
func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws {
var storedHandler: ((NostrEvent) -> Void)?
let now = Date(timeIntervalSince1970: 1_700_000_000)
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, _ in
storedHandler = handler
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { now }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
let identity = try NostrIdentity.generate()
let expired = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: now.addingTimeInterval(-3600),
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]],
content: "gone"
)
storedHandler?(try expired.sign(with: identity.schnorrSigningKey()))
let live = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: now.addingTimeInterval(-3600),
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]],
content: "still here"
)
storedHandler?(try live.sign(with: identity.schnorrSigningKey()))
#expect(manager.notes.count == 1)
#expect(manager.notes.first?.content == "still here")
#expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600)))
}
@Test
func postDrop_sendsExpiringNoteToGeoRelays() throws {
var sentEvents: [NostrEvent] = []
let now = Date(timeIntervalSince1970: 1_700_000_000)
let identity = try NostrIdentity.generate()
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { event, _ in sentEvents.append(event) },
deriveIdentity: { _ in identity },
now: { now }
)
let posted = LocationNotesManager.postDrop(
content: " the coffee here is great ",
nickname: "scout",
geohash: "u4pruydq",
dependencies: deps
)
#expect(posted)
#expect(sentEvents.count == 1)
let event = try #require(sentEvents.first)
#expect(event.kind == NostrProtocol.EventKind.textNote.rawValue)
#expect(event.content == "the coffee here is great")
#expect(event.tags.contains(["g", "u4pruydq"]))
let expiration = event.tags.first { $0.first == "expiration" }?.last
let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970)
#expect(expiration == String(expected))
}
@Test
func postDrop_failsWithoutRelays() {
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
#expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps))
}
@Test
func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws {
var storedHandler: ((NostrEvent) -> Void)?
var currentNow = Date(timeIntervalSince1970: 1_700_000_000)
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, _ in
storedHandler = handler
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { currentNow }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
let identity = try NostrIdentity.generate()
let note = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: currentNow,
kind: .textNote,
tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]],
content: "short lived"
)
storedHandler?(try note.sign(with: identity.schnorrSigningKey()))
#expect(manager.notes.count == 1)
currentNow = currentNow.addingTimeInterval(120)
manager.pruneExpiredNotes()
#expect(manager.notes.isEmpty)
}
private enum TestError: Error {
case shouldNotDerive
}
@@ -0,0 +1,113 @@
//
// GeohashChatActivityTrackerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@MainActor
struct GeohashChatActivityTrackerTests {
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private func makeTracker(window: TimeInterval = 900, now: Date? = nil) -> (GeohashChatActivityTracker, (Date) -> Void) {
var currentNow = now ?? baseDate
let tracker = GeohashChatActivityTracker(window: window, now: { currentNow })
return (tracker, { currentNow = $0 })
}
private func channel(_ geohash: String, _ level: GeohashChannelLevel) -> GeohashChannel {
GeohashChannel(level: level, geohash: geohash)
}
@Test
func recordsAndCountsMessagesInWindow() {
let (tracker, _) = makeTracker()
tracker.recordChatMessage(geohash: "9Q8YY", senderName: "alice#ab12", content: "hi", timestamp: baseDate)
tracker.recordChatMessage(geohash: "9q8yy", senderName: "bob#cd34", content: "yo", timestamp: baseDate)
#expect(tracker.messageCount(for: "9q8yy") == 2)
#expect(tracker.lastMessage(for: "9q8YY")?.senderName == "bob#cd34")
}
@Test
func dropsMessagesOlderThanWindow() {
let (tracker, advance) = makeTracker(window: 900)
tracker.recordChatMessage(geohash: "9q8yy", senderName: "alice#ab12", content: "hi", timestamp: baseDate)
advance(baseDate.addingTimeInterval(901))
#expect(tracker.messageCount(for: "9q8yy") == 0)
#expect(tracker.lastMessage(for: "9q8yy") == nil)
}
@Test
func ignoresMessagesAlreadyOutsideWindow() {
let (tracker, _) = makeTracker(window: 900)
tracker.recordChatMessage(
geohash: "9q8yy",
senderName: "alice#ab12",
content: "old",
timestamp: baseDate.addingTimeInterval(-1000)
)
#expect(tracker.messageCount(for: "9q8yy") == 0)
}
@Test
func keepsNewestPreview() {
let (tracker, _) = makeTracker()
tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "newer", timestamp: baseDate)
tracker.recordChatMessage(geohash: "9q8yy", senderName: "b#2222", content: "older", timestamp: baseDate.addingTimeInterval(-60))
#expect(tracker.lastMessage(for: "9q8yy")?.content == "newer")
#expect(tracker.messageCount(for: "9q8yy") == 2)
}
@Test
func mostActivePicksBusiestChannel() {
let (tracker, _) = makeTracker()
tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "one", timestamp: baseDate)
tracker.recordChatMessage(geohash: "9q8", senderName: "b#2222", content: "two", timestamp: baseDate)
tracker.recordChatMessage(geohash: "9q8", senderName: "c#3333", content: "three", timestamp: baseDate)
let channels = [channel("9q8yy", .city), channel("9q8", .province)]
let best = tracker.mostActiveConversation(among: channels)
#expect(best?.channel.geohash == "9q8")
#expect(best?.messageCount == 2)
}
@Test
func mostActiveTieGoesToMoreLocalChannel() {
let (tracker, _) = makeTracker()
tracker.recordChatMessage(geohash: "9q8yyzz1", senderName: "a#1111", content: "local", timestamp: baseDate)
tracker.recordChatMessage(geohash: "9q", senderName: "b#2222", content: "regional", timestamp: baseDate)
let channels = [channel("9q", .region), channel("9q8yyzz1", .building)]
let best = tracker.mostActiveConversation(among: channels)
#expect(best?.channel.geohash == "9q8yyzz1")
}
@Test
func mostActiveIsNilWithoutMessages() {
let (tracker, _) = makeTracker()
#expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil)
}
@Test
func clearRemovesEverything() {
let (tracker, _) = makeTracker()
tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate)
tracker.clear()
#expect(tracker.messageCount(for: "9q8yy") == 0)
#expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil)
}
}
@@ -0,0 +1,81 @@
//
// MeshSightingsTrackerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
import BitFoundation
@MainActor
struct MeshSightingsTrackerTests {
private func makeDefaults() -> UserDefaults {
let suite = "MeshSightingsTrackerTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
private let noon = Date(timeIntervalSince1970: 1_700_000_000)
@Test
func countsDistinctPeersOnce() {
let defaults = makeDefaults()
let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon })
tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666"))
#expect(tracker.todayCount == 2)
#expect(tracker.lastSightingAt == noon)
}
@Test
func persistsAcrossInstances() {
let defaults = makeDefaults()
let first = MeshSightingsTracker(defaults: defaults, now: { self.noon })
first.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
let second = MeshSightingsTracker(defaults: defaults, now: { self.noon.addingTimeInterval(60) })
#expect(second.todayCount == 1)
// Same peer again does not double count after a relaunch.
second.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
#expect(second.todayCount == 1)
}
@Test
func rollsOverOnNewDay() {
let defaults = makeDefaults()
var currentNow = noon
let tracker = MeshSightingsTracker(defaults: defaults, now: { currentNow })
tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
#expect(tracker.todayCount == 1)
currentNow = noon.addingTimeInterval(2 * 24 * 60 * 60)
tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666"))
#expect(tracker.todayCount == 1)
}
@Test
func clearResetsEverything() {
let defaults = makeDefaults()
let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon })
tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333"))
tracker.clear()
#expect(tracker.todayCount == 0)
#expect(tracker.lastSightingAt == nil)
let reloaded = MeshSightingsTracker(defaults: defaults, now: { self.noon })
#expect(reloaded.todayCount == 0)
}
}
+1 -1
View File
@@ -415,7 +415,7 @@ struct ViewSmokeTests {
let board = BoardManager(
transport: transport,
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
publishToNostr: { _, _, _, _ in nil },
publishToNostr: { _, _, _, _, _ in nil },
deleteFromNostr: { _, _ in }
)