Unified notices: one pin, one sheet for board pins + location notes (#1392)

* Unified notices: merge board pins and location notes into one sheet

One pin icon in the header now opens a single Notices sheet with a
geo/mesh scope toggle, replacing the separate board, location-notes,
and mesh-only note buttons:

- geo tab: current geohash's notices — mesh-synced board posts merged
  and deduped with Nostr kind-1 location notes, with per-item mesh/net
  source badges. Scope follows the selected location channel, or the
  device's building geohash when chatting on mesh.
- mesh tab: mesh-local board only (fully offline).
- One composer: geo posts go to the board and bridge to Nostr (existing
  bridge), so mesh and internet see the same notice.
- Merged delete: tombstoning an own board post now also retracts the
  bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event
  ids tracked in BoardManager); own Nostr-only notes are deletable too.
- LocationNotesManager accepts any channel-precision geohash (1-12
  chars), not just building-level.

BoardView and LocationNotesView are superseded by NoticesView.

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

* Notices round 2: honest composer, friendlier copy, new-pin chat alerts

- Urgent + expiry controls now appear on the mesh tab only: the bridged
  Nostr copy of a geo post carries neither, so relay-side readers would
  never see them. Geo posts default to non-urgent with 7-day expiry, and
  the bridged note now gets a NIP-40 expiration tag so honoring relays
  drop it in step with the board copy.
- Geo tab explainer reuses the original location-notes description
  (keeps its 29 existing translations); mesh tab gets a new plain-
  language description.
- New-pin chat alerts, fully local (no wire traffic): BoardStore fires
  postArrivals for posts newly accepted from the wire; BoardAlertsModel
  filters own posts, dedups by postID, and for urgent pins created
  within the last 30 minutes emits one system line into the matching
  timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat),
  collapsing simultaneous arrivals into a count line.
- Routine pins light up the header: the pin icon tints orange whenever
  the current scope has notices at all, and fills (pin.fill) while
  unseen new pins are waiting; opening the sheet clears them.

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

* i18n: translate the unified-notices strings into all 28 non-English locales

Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh
description, source badges, urgent alert lines, button tooltip and
accessibility strings) to the string catalog with translations for
every locale the app ships. The geo tab already reuses the fully
translated location_notes.description; this covers the rest. Insertion
preserves the catalog's case-insensitive key order, so the diff is
purely additive.

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

* Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe

- BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and
  resets, so a panic wipe drops pending urgent lines (which could
  otherwise re-append pre-wipe content into chat after the collapse
  flush), unseen badge scopes, and handled-post history.
- Opening the notices sheet clears unseen badges only for the scopes it
  actually shows (mesh + current geo scope); pins for other geohash
  channels keep their badge until visited.
- LocationNotesManager.Note now retains the matched g tag, and the
  bridged-copy dedupe requires the note's geohash to equal the board
  post's — a same-text note from a neighboring cell is no longer
  swallowed as a duplicate.

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-07 17:40:12 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent a0c517c018
commit 8415c52913
19 changed files with 6210 additions and 3139 deletions
+19
View File
@@ -28,6 +28,7 @@ final class AppRuntime: ObservableObject {
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
@@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject {
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
)
let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel(
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { post in
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
},
emitSystemLine: { content, geohash in
if geohash.isEmpty {
chatViewModel.addMeshOnlySystemMessage(content)
} else {
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
}
}
)
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
+1
View File
@@ -40,6 +40,7 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
+4765 -2422
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -19,6 +19,7 @@ struct NostrProtocol {
case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
}
/// Create a NIP-17 private message
@@ -256,16 +257,22 @@ struct NostrProtocol {
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
nickname: String? = nil,
expiresAt: Date? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
@@ -277,6 +284,24 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey)
}
/// Create a NIP-09 deletion request for one of our own events. Relays that
/// honor NIP-09 drop the referenced event; it must be signed by the same
/// key that signed the original.
static func createDeleteEvent(
ofEventID eventID: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .deletion,
tags: [["e", eventID]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods
private static func createSeal(
+8
View File
@@ -18,6 +18,14 @@ enum Geohash {
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Validates a geohash string at any channel precision (1-12 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
static func isValidGeohash(_ geohash: String) -> Bool {
guard (1...12).contains(geohash.count) else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Encodes the provided coordinates into a geohash string.
/// - Parameters:
/// - latitude: Latitude in degrees (-90...90)
@@ -0,0 +1,160 @@
//
// BoardAlertsModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
/// Turns newly arriving board posts into local, scope-matched chat alerts.
/// Everything here is derived from posts the mesh already synced no extra
/// wire traffic, nothing another peer can't already see.
///
/// - Urgent, recent pins get one system line in the matching chat (geo pin
/// that geohash's timeline, mesh pin mesh chat), collapsed when several
/// arrive together.
/// - Every other new pin just marks the header's pin icon until the notices
/// sheet is opened.
@MainActor
final class BoardAlertsModel: ObservableObject {
struct Dependencies {
/// Own posts never alert; the author already knows.
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
/// Appends a local system line to a scope's chat timeline
/// (geohash, or "" for mesh chat).
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
var now: () -> Date = Date.init
/// Schedules the collapsed flush of pending urgent alerts; tests
/// inject a synchronous hook.
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
flush()
}
}
}
/// Posts older than this at arrival are backfilled history carried in by
/// a peer, not something happening now; they badge but never line the chat.
static let inlineRecencyWindow: TimeInterval = 30 * 60
/// Urgent arrivals within this window collapse into one line.
static let collapseDelaySeconds: TimeInterval = 4
private static let alertContentMaxChars = 120
/// Unseen new pins by postID (hex) geohash scope, cleared when the
/// notices sheet opens.
@Published private(set) var unseenPostScopes: [String: String] = [:]
/// PostIDs already handled this session, so store eviction/re-sync churn
/// can't re-alert. Bounded by session wire volume (32-byte strings).
private var handledPostIDs = Set<String>()
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
private var flushScheduled = false
private let dependencies: Dependencies
private var cancellable: AnyCancellable?
private var wipeCancellable: AnyCancellable?
private enum Strings {
static func urgentSingle(author: String, content: String) -> String {
String(
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
locale: .current,
author, content
)
}
static func urgentCollapsed(_ count: Int) -> String {
String(
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
locale: .current,
count
)
}
}
init(
arrivals: AnyPublisher<BoardPostPacket, Never>,
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: Dependencies
) {
self.dependencies = dependencies
cancellable = arrivals
.receive(on: DispatchQueue.main)
.sink { [weak self] post in
self?.handleArrival(post)
}
wipeCancellable = wipes
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.reset()
}
}
func unseenCount(forGeohash geohash: String) -> Int {
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
}
/// Marks pins in the given scopes as seen only the scopes the notices
/// sheet actually shows, so unseen pins for other geohash channels keep
/// their badge until visited.
func markSeen(forScopes scopes: Set<String>) {
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
}
/// Panic wipe: drop everything derived from pre-wipe posts, including
/// urgent lines still waiting on the collapse flush.
func reset() {
pendingUrgent.removeAll()
handledPostIDs.removeAll()
guard !unseenPostScopes.isEmpty else { return }
unseenPostScopes.removeAll()
}
func handleArrival(_ post: BoardPostPacket) {
let postID = post.postID.hexEncodedString()
guard !handledPostIDs.contains(postID) else { return }
handledPostIDs.insert(postID)
guard !dependencies.isOwnPost(post) else { return }
unseenPostScopes[postID] = post.geohash
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
guard post.isUrgent,
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
return
}
pendingUrgent[post.geohash, default: []].append(post)
if !flushScheduled {
flushScheduled = true
dependencies.scheduleFlush { [weak self] in
self?.flushPendingUrgent()
}
}
}
private func flushPendingUrgent() {
flushScheduled = false
let pending = pendingUrgent
pendingUrgent.removeAll()
for (geohash, posts) in pending {
guard let first = posts.first else { continue }
let line: String
if posts.count == 1 {
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
} else {
line = Strings.urgentCollapsed(posts.count)
}
dependencies.emitSystemLine(line, geohash)
}
}
private static func truncated(_ content: String) -> String {
guard content.count > alertContentMaxChars else { return content }
return content.prefix(alertContentMaxChars) + ""
}
}
+41 -9
View File
@@ -20,17 +20,28 @@ final class BoardManager: ObservableObject {
private let transport: Transport
private let store: BoardStore
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
/// 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?
/// 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:
/// after a relaunch a delete still tombstones the board copy, but the
/// Nostr copy is left to expire with relay retention.
private var bridgedEventIDs: [Data: String] = [:]
private var cancellable: AnyCancellable?
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String) -> Void)? = nil
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
) {
self.transport = transport
self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
cancellable = store.$postsSnapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
@@ -115,10 +126,10 @@ final class BoardManager: ObservableObject {
)
transport.sendBoardPayload(BoardWire.post(post).encode())
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
// location notes so online users see them. No inbound merge yet.
if !geohash.isEmpty {
publishToNostr(trimmed, geohash, cleanNickname)
// 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) {
bridgedEventIDs[postID] = eventID
}
return true
}
@@ -140,14 +151,20 @@ final class BoardManager: ObservableObject {
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
deleteFromNostr(eventID, post.geohash)
}
return true
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> 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)
return
return nil
}
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
@@ -155,11 +172,26 @@ final class BoardManager: ObservableObject {
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: nickname
nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
)
NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
} catch {
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
return nil
}
}
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { return }
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
NostrRelayManager.shared.sendEvent(deletion, to: relays)
} catch {
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
}
}
}
+18
View File
@@ -78,6 +78,16 @@ final class BoardStore {
/// Live posts, published on the main thread for the board UI.
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
/// Fires on the main thread for each post newly accepted from the wire
/// (radio, sync, or local echo) not for disk restores. Drives the
/// local new-pin chat alerts; duplicates never fire twice because the
/// store rejects them.
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
/// Fires on the main thread after a panic wipe so derived state (pending
/// alerts, unseen badges) is dropped along with the posts themselves.
let didWipe = PassthroughSubject<Void, Never>()
private var posts: [StoredPost] = []
private var tombstones: [StoredTombstone] = []
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
@@ -104,6 +114,11 @@ final class BoardStore {
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
if result == .accepted {
persistLocked()
if case .post(let post) = wire {
DispatchQueue.main.async { [weak self] in
self?.postArrivals.send(post)
}
}
}
return result
}
@@ -149,6 +164,9 @@ final class BoardStore {
}
publishSnapshotLocked()
}
DispatchQueue.main.async { [weak self] in
self?.didWipe.send()
}
}
// MARK: - Internals (call only on `queue`)
@@ -0,0 +1,88 @@
//
// UnifiedNotices.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// One row in the unified notices sheet: a mesh board post or a Nostr
/// location note, normalized for display.
struct NoticeItem: Identifiable, Equatable {
enum Source: Equatable {
/// Signed board post carried by the mesh.
case board(BoardPostPacket)
/// Kind-1 location note seen on geo relays.
case nostr(LocationNotesManager.Note)
}
let id: String
let author: String
let content: String
let createdAt: Date
let isUrgent: Bool
let source: Source
var isBoardPost: Bool {
if case .board = source { return true }
return false
}
init(post: BoardPostPacket) {
id = post.postID.hexEncodedString()
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
content = post.content
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
isUrgent = post.isUrgent
source = .board(post)
}
init(note: LocationNotesManager.Note) {
id = note.id
let display = note.displayName
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first.map(String.init) ?? display
content = note.content
createdAt = note.createdAt
isUrgent = false
source = .nostr(note)
}
}
/// Merges mesh board posts and Nostr location notes into one deduplicated
/// list for the notices sheet's geo tab.
enum UnifiedNotices {
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
/// post time, so the same notice arrives twice. The copies share content
/// and nickname but are signed by unlinkable keys; match them
/// heuristically by content + author within a time window.
static let bridgeDedupeWindow: TimeInterval = 15 * 60
/// Returns board posts and notes as one list, urgent posts first, then
/// newest first. Notes that look like bridged copies of a board post are
/// dropped the board copy wins because it carries urgency and supports
/// merged deletion. The geohash must match exactly: the notes
/// subscription also surfaces neighboring cells, and a same-text note
/// from a neighbor is not the bridged copy.
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
var items = posts.map(NoticeItem.init(post:))
for note in notes {
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
let isBridgedCopy = posts.contains { post in
post.geohash == note.geohash
&& post.content == note.content
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
}
if !isBridgedCopy {
items.append(NoticeItem(note: note))
}
}
return items.sorted {
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
return $0.createdAt > $1.createdAt
}
}
}
+46 -10
View File
@@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject {
let content: String
let createdAt: Date
let nickname: String?
/// The matched `g` tag: the cell the note was posted to, which can be
/// a neighbor of the subscribed geohash.
let geohash: String
var displayName: String {
let suffix = String(pubkey.suffix(4))
@@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
/// Public key of our per-geohash Nostr identity; identifies our own notes.
private var ownPubkey: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
@@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject {
let norm = geohash.lowercased()
self.geohash = norm
self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars)
if !Geohash.isValidBuildingGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
if !Geohash.isValidGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
}
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
subscribe()
// The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays".
@@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject {
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
guard Geohash.isValidGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID {
@@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject {
initialLoadComplete = false
errorMessage = nil
geohash = norm
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
notes.removeAll()
noteIDs.removeAll()
subscribe()
@@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject {
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in
guard let matchedGeohash = event.tags.first(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
})?[1].lowercased() else { return }
guard !self.noteIDs.contains(event.id) else { 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)
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
@@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject {
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname
nickname: nickname,
geohash: geohash
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
@@ -252,6 +258,36 @@ final class LocationNotesManager: ObservableObject {
}
}
/// Whether the note was published by this device's identity for the
/// current geohash (and can therefore be deleted with NIP-09).
func isOwnNote(_ note: Note) -> Bool {
guard let ownPubkey else { return false }
return note.pubkey == ownPubkey
}
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
@discardableResult
func delete(note: Note) -> Bool {
guard isOwnNote(note) else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
dependencies.sendEvent(deletion, relays)
// Keep the id in noteIDs so a relay replay can't resurrect it.
notes.removeAll { $0.id == note.id }
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
return false
}
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
+13
View File
@@ -1744,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func addGeohashOnlySystemMessage(_ content: String) {
publicConversationCoordinator.addGeohashOnlySystemMessage(content)
}
/// Add a local system message to one specific geohash timeline, active or
/// not. Used by the board's new-pin alerts to scope-match the pin's channel.
@MainActor
func addGeohashSystemMessage(_ content: String, geohash: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash)
}
// Send a public message without adding a local user echo.
// Used for emotes where we want a local system-style confirmation instead.
@MainActor
-270
View File
@@ -1,270 +0,0 @@
//
// BoardView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The bulletin board for one context: a geohash channel, or the mesh-local
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
/// be swipe-deleted, which broadcasts a signed tombstone.
struct BoardView: View {
/// Empty string = mesh-local board.
let geohash: String
let senderNickname: String
@ObservedObject var board: BoardManager
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
private enum Strings {
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
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 closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
static func expiryDaysOption(_ days: Int) -> String {
String(
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
locale: .current,
days
)
}
static func postAccessibilityLabel(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"),
locale: .current,
author, content
)
return urgent ? "\(urgentBadge), \(base)" : base
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
postList
composer
}
.themedSurface()
#if os(macOS)
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
.bitchatFont(size: 18)
Spacer()
SheetCloseButton { dismiss() }
.accessibilityLabel(Strings.closeHint)
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private var postList: some View {
Group {
if posts.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
} else {
List {
ForEach(posts, id: \.postID) { post in
postRow(post)
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private func postRow(_ post: BoardPostPacket) -> some View {
let isOwn = board.isOwnPost(post)
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if post.isUrgent {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 11))
.foregroundColor(palette.alertRed)
Text(Strings.urgentBadge)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.alertRed)
}
Text(verbatim: "@\(author)")
.bitchatFont(size: 12, weight: .semibold)
Text(timestampText(forMs: post.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
Spacer()
if isOwn {
Button {
board.deletePost(post)
} label: {
Image(systemName: "trash")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.deleteAction)
}
}
Text(post.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
.accessibilityActions {
if isOwn {
Button(Strings.deleteAction) { board.deletePost(post) }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if isOwn {
Button(role: .destructive) {
board.deletePost(post)
} label: {
Label(Strings.deleteAction, systemImage: "trash")
}
}
}
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.placeholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendEnabled ? palette.accent : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendEnabled)
.accessibilityLabel(Strings.send)
}
HStack(spacing: 12) {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
}
.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)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private var sendEnabled: Bool {
let trimmed = draft.trimmed
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
}
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: urgent,
expiryDays: expiryDays,
nickname: senderNickname
)
if sent {
draft = ""
urgent = false
}
}
private func timestampText(forMs ms: UInt64) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
}
return Self.absDateFormatter.string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
}
+64 -105
View File
@@ -1,21 +1,17 @@
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ContentHeaderView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var boardAlertsModel: BoardAlertsModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
@Binding var showLocationNotes: Bool
@Binding var notesGeohash: String?
var isNicknameFieldFocused: FocusState<Bool>.Binding
let headerHeight: CGFloat
@@ -25,8 +21,13 @@ struct ContentHeaderView: View {
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
/// Bulletin board sheet for the current channel context.
@State private var showBoard = false
/// 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] = []
var body: some View {
HStack(spacing: 0) {
@@ -137,36 +138,40 @@ struct ContentHeaderView: View {
)
}
if case .mesh = locationChannelsModel.selectedChannel,
locationChannelsModel.permissionState == .authorized {
Button(action: {
locationChannelsModel.enableAndRefresh()
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
var scopes: Set<String> = [""]
if let geoScope = noticesGeoScope {
scopes.insert(geoScope)
}
boardAlertsModel.markSeen(forScopes: scopes)
showNotices = true
}) {
Image(systemName: "note.text")
// Fill marks unseen new pins; the tint says the current
// scope has notices at all.
Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
.foregroundColor(
scopeHasNotices || unseenNoticesCount > 0
? Color.orange.opacity(0.8)
: palette.secondary.opacity(0.9)
)
}
Button(action: { showBoard = true }) {
Image(systemName: "pin")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.9))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button")
)
.accessibilityValue(
unseenNoticesCount > 0
? String(
format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"),
locale: .current,
unseenNoticesCount
)
: ""
)
.help(
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
)
if case .location(let channel) = locationChannelsModel.selectedChannel {
@@ -267,54 +272,21 @@ struct ContentHeaderView: View {
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.onReceive(BoardStore.shared.$postsSnapshot) { posts in
boardPosts = posts
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
.sheet(isPresented: $showBoard) {
BoardView(
geohash: boardGeohash,
.sheet(isPresented: $showNotices) {
NoticesView(
senderNickname: appChromeModel.nickname,
board: appChromeModel.boardManager
)
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
Group {
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
LocationNotesView(
geohash: geohash,
senderNickname: appChromeModel.nickname
board: appChromeModel.boardManager,
initialTab: initialNoticesTab
)
.environmentObject(locationChannelsModel)
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight
)
.environmentObject(locationChannelsModel)
}
}
.onAppear {
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
}
.onChange(of: locationChannelsModel.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
}
.onAppear {
locationChannelsModel.refreshMeshChannelsIfNeeded()
@@ -348,13 +320,34 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
/// The board scope for the current channel: the geohash channel's board,
/// or the mesh-local board ("") in mesh chat.
var boardGeohash: String {
/// Open the notices sheet on the tab matching the current channel: the
/// geohash channel's notices, or the mesh-local board in mesh chat.
var initialNoticesTab: NoticesView.Tab {
if case .location = locationChannelsModel.selectedChannel {
return .geo
}
return .mesh
}
/// The geo scope the notices sheet would open on: the selected location
/// channel, or the device's building geohash when chatting on mesh.
var noticesGeoScope: String? {
if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash
}
return ""
return locationChannelsModel.currentBuildingGeohash
}
/// Whether either tab of the notices sheet currently has content.
var scopeHasNotices: Bool {
boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope }
}
/// New pins in either visible scope since the sheet was last opened.
var unseenNoticesCount: Int {
let meshCount = boardAlertsModel.unseenCount(forGeohash: "")
let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0
return meshCount + geoCount
}
/// Whether anyone is actually reachable on the current channel the
@@ -380,37 +373,3 @@ private extension ContentHeaderView {
}
}
}
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
SheetCloseButton { showLocationNotes = false }
.foregroundColor(palette.primary)
}
.frame(minHeight: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.themedSheetBackground()
.foregroundColor(palette.primary)
}
}
-4
View File
@@ -49,8 +49,6 @@ struct ContentView: View {
@State private var isAtBottomPrivate = true
@State private var autocompleteDebounceTimer: Timer?
@State private var showVerifySheet = false
@State private var showLocationNotes = false
@State private var notesGeohash: String?
@State private var imagePreviewURL: URL?
#if os(iOS)
@State private var showImagePicker = false
@@ -269,8 +267,6 @@ struct ContentView: View {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
-304
View File
@@ -1,304 +0,0 @@
import SwiftUI
struct LocationNotesView: View {
@StateObject private var manager: LocationNotesManager
let geohash: String
let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)?
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
init(
geohash: String,
senderNickname: String,
onNotesCountChanged: ((Int) -> Void)? = nil,
manager: LocationNotesManager? = nil
) {
let gh = geohash.lowercased()
self.geohash = gh
self.senderNickname = senderNickname
self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { palette.background }
private var accentGreen: Color { palette.accent }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
static let retry: LocalizedStringKey = "location_notes.action.retry"
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
}
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
headerSection
notesContent
}
}
.themedSurface()
inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#else
NavigationView {
VStack(spacing: 0) {
headerSection
ScrollView {
notesContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#endif
}
private var closeButton: some View {
SheetCloseButton { dismiss() }
}
private var headerSection: some View {
let count = manager.notes.count
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(headerTitle(for: count))
.bitchatFont(size: 18)
Spacer()
closeButton
}
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building)
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block)
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private func headerTitle(for count: Int) -> String {
String(
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current,
"\(geohash) ± 1", count
)
}
private var notesContent: some View {
LazyVStack(alignment: .leading, spacing: 12) {
if manager.state == .noRelays {
noRelaysRow
} else if manager.state == .loading && !manager.initialLoadComplete {
loadingRow
} else if manager.notes.isEmpty {
emptyRow
} else {
ForEach(manager.notes) { note in
noteRow(note)
}
}
if let error = manager.errorMessage, manager.state != .noRelays {
errorRow(message: error)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
let ts = timestampText(for: note.createdAt)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(verbatim: "@\(baseName)")
.bitchatFont(size: 12, weight: .semibold)
if !ts.isEmpty {
Text(ts)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
Spacer()
}
Text(note.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
}
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
}
private func errorRow(message: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.bitchatFont(size: 12)
Text(message)
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { manager.clearError() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendButtonEnabled)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
manager.send(content: content, nickname: senderNickname)
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmed.isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
} else {
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date)
}
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}
+560
View File
@@ -0,0 +1,560 @@
//
// NoticesView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The unified notices sheet behind the header's pin icon: one place for
/// everything pinned around you, with a scope toggle.
///
/// - geo: the current geohash's notices mesh-synced board posts merged and
/// deduped with Nostr kind-1 location notes, so you also see notices from
/// people who aren't on your mesh.
/// - mesh: the mesh-local board only (empty geohash, fully offline).
struct NoticesView: View {
enum Tab: Hashable {
case geo
case mesh
}
let senderNickname: String
@ObservedObject var board: BoardManager
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@State private var tab: Tab
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
/// Injected notes manager for tests; live use derives one per geohash.
private let notesManager: LocationNotesManager?
init(
senderNickname: String,
board: BoardManager,
initialTab: Tab,
notesManager: LocationNotesManager? = nil
) {
self.senderNickname = senderNickname
self.board = board
self.notesManager = notesManager
_tab = State(initialValue: initialTab)
}
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
/// The geohash the geo tab is scoped to: the selected location channel,
/// or the device's building geohash when chatting on mesh.
private var geoGeohash: String? {
if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash
}
return locationChannelsModel.currentBuildingGeohash
}
/// The geo scope comes from device location only when no location channel
/// is selected; that's the case that needs the location machinery.
private var geoTabNeedsDeviceLocation: Bool {
if case .location = locationChannelsModel.selectedChannel { return false }
return true
}
private var activeGeohash: String? {
switch tab {
case .geo: return geoGeohash
case .mesh: return ""
}
}
enum Strings {
static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
// The pre-merge location-notes explainer, reused so its existing
// translations carry over.
static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
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 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")
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
static let retry: LocalizedStringKey = "location_notes.action.retry"
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
static func expiryDaysOption(_ days: Int) -> String {
String(
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
locale: .current,
days
)
}
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"),
locale: .current,
author, content
)
return urgent ? "\(urgentBadge), \(base)" : base
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
contentSection
if activeGeohash != nil {
composer
}
}
.themedSurface()
#if os(macOS)
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
.onAppear { beginGeoLocationIfNeeded() }
.onChange(of: tab) { newTab in
if newTab == .geo {
beginGeoLocationIfNeeded()
} else {
locationChannelsModel.endLiveRefresh()
}
}
// Catches permission granted from the geo tab's enable button.
.onChange(of: locationChannelsModel.permissionState) { _ in
beginGeoLocationIfNeeded()
}
.onDisappear { locationChannelsModel.endLiveRefresh() }
}
/// The geo tab tracks the device's building geohash while on mesh; keep
/// location fresh only in that case (a selected location channel already
/// fixes the scope).
private func beginGeoLocationIfNeeded() {
guard tab == .geo, geoTabNeedsDeviceLocation,
locationChannelsModel.permissionState == .authorized else { return }
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(verbatim: scopeTitle)
.bitchatFont(size: 18)
Spacer()
SheetCloseButton { dismiss() }
.accessibilityLabel(Strings.closeHint)
}
Picker(Strings.scopePicker, selection: $tab) {
Text(Strings.geoTab).tag(Tab.geo)
Text(Strings.meshTab).tag(Tab.mesh)
}
.pickerStyle(.segmented)
.accessibilityLabel(Strings.scopePicker)
Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private var scopeTitle: String {
switch tab {
case .mesh:
return "\(Strings.title) @ #mesh"
case .geo:
if let geohash = geoGeohash {
return "\(Strings.title) @ #\(geohash)"
}
return Strings.title
}
}
@ViewBuilder
private var contentSection: some View {
switch tab {
case .mesh:
NoticesList(
items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
showsSource: false,
board: board,
notesManager: nil
)
case .geo:
if let geohash = geoGeohash {
GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
} else {
locationUnavailableSection
}
}
}
private var locationUnavailableSection: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(Strings.locationUnavailable)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
Button(Strings.enableLocation) {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.placeholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendEnabled ? palette.accent : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.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) {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
}
.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)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private var sendEnabled: Bool {
let trimmed = draft.trimmed
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
}
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).
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: tab == .mesh && urgent,
expiryDays: tab == .mesh ? expiryDays : 7,
nickname: senderNickname
)
if sent {
draft = ""
urgent = false
}
}
}
/// 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.
private struct GeoNoticesList: View {
let geohash: String
@ObservedObject var board: BoardManager
@StateObject private var notesManager: LocationNotesManager
init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
let gh = geohash.lowercased()
self.geohash = gh
self.board = board
_notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
var body: some View {
NoticesList(
items: UnifiedNotices.merge(
posts: board.posts(forGeohash: geohash),
notes: notesManager.notes
),
showsSource: true,
board: board,
notesManager: notesManager
)
.onChange(of: geohash) { newValue in
notesManager.setGeohash(newValue)
}
.onDisappear { notesManager.cancel() }
}
}
/// Renders merged notices with per-source affordances: swipe-delete for own
/// items and a mesh/net badge when sources mix.
private struct NoticesList: View {
let items: [NoticeItem]
let showsSource: Bool
let board: BoardManager
let notesManager: LocationNotesManager?
@ThemedPalette private var palette
private typealias Strings = NoticesView.Strings
var body: some View {
Group {
if items.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
statusRows
if showEmptyState {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
} else {
List {
statusRows
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
ForEach(items) { item in
row(item)
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
/// Notes may still be loading or unreachable; only claim "no notices yet"
/// once the sources settled.
private var showEmptyState: Bool {
guard let notesManager else { return true }
return notesManager.initialLoadComplete && notesManager.state != .loading
}
@ViewBuilder
private var statusRows: some View {
if let notesManager {
if notesManager.state == .loading && !notesManager.initialLoadComplete {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
} else if notesManager.state == .noRelays {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(Strings.retry) { notesManager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.bottom, 8)
} else if let error = notesManager.errorMessage {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.bitchatFont(size: 12)
Text(error)
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { notesManager.clearError() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.bottom, 8)
}
}
}
private func canDelete(_ item: NoticeItem) -> Bool {
switch item.source {
case .board(let post):
return board.isOwnPost(post)
case .nostr(let note):
return notesManager?.isOwnNote(note) ?? false
}
}
private func delete(_ item: NoticeItem) {
switch item.source {
case .board(let post):
// Tombstones the board post and retracts the bridged Nostr copy.
board.deletePost(post)
case .nostr(let note):
notesManager?.delete(note: note)
}
}
private func row(_ item: NoticeItem) -> some View {
let isOwn = canDelete(item)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if item.isUrgent {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 11))
.foregroundColor(palette.alertRed)
Text(Strings.urgentBadge)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.alertRed)
}
Text(verbatim: "@\(item.author)")
.bitchatFont(size: 12, weight: .semibold)
Text(Self.timestampText(for: item.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
Spacer()
if showsSource {
sourceBadge(item)
}
if isOwn {
Button {
delete(item)
} label: {
Image(systemName: "trash")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.deleteAction)
}
}
Text(item.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
.accessibilityActions {
if isOwn {
Button(Strings.deleteAction) { delete(item) }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if isOwn {
Button(role: .destructive) {
delete(item)
} label: {
Label(Strings.deleteAction, systemImage: "trash")
}
}
}
}
private func sourceBadge(_ item: NoticeItem) -> some View {
HStack(spacing: 3) {
Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
.font(.bitchatSystem(size: 10))
Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
.bitchatFont(size: 10)
}
.foregroundColor(palette.secondary.opacity(0.8))
.accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
}
// MARK: - Timestamp Formatting
private static func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
}
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}
@@ -0,0 +1,215 @@
//
// BoardAlertsModelTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
import Testing
@testable import bitchat
@MainActor
struct BoardAlertsModelTests {
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private let ownKey = Data(repeating: 7, count: 32)
private final class Harness {
var lines: [(content: String, geohash: String)] = []
var pendingFlushes: [@MainActor () -> Void] = []
@MainActor
func flushAll() {
let flushes = pendingFlushes
pendingFlushes = []
for flush in flushes { flush() }
}
}
private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel {
let fixedNow = now ?? baseDate
return BoardAlertsModel(
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey },
emitSystemLine: { content, geohash in
harness.lines.append((content, geohash))
},
now: { fixedNow },
scheduleFlush: { flush in
harness.pendingFlushes.append(flush)
}
)
)
}
private func makePost(
content: String = "hello",
geohash: String = "9q8yy",
nickname: String = "alice",
createdAt: UInt64? = nil,
urgent: Bool = false,
authorKey: Data = Data(repeating: 1, count: 32),
postID: Data? = nil
) -> BoardPostPacket {
BoardPostPacket(
postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
geohash: geohash,
content: content,
authorSigningKey: authorKey,
authorNickname: nickname,
createdAt: createdAt ?? baseMs,
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
flags: urgent ? BoardPostPacket.urgentFlag : 0,
signature: Data(repeating: 2, count: 64)
)
}
@Test
func ownPosts_neverBadgeOrAlert() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(urgent: true, authorKey: ownKey))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(harness.lines.isEmpty)
}
@Test
func routinePost_badgesWithoutChatLine() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(geohash: ""))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "") == 1)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(harness.lines.isEmpty)
}
@Test
func urgentRecentPost_emitsLineInMatchingScope() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true))
#expect(harness.lines.isEmpty)
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].geohash == "9q8yy")
#expect(harness.lines[0].content.contains("road closed"))
#expect(harness.lines[0].content.contains("@alice"))
}
@Test
func urgentBackfilledPost_badgesOnly() {
let harness = Harness()
let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120)
let model = makeModel(harness: harness, now: arrivalTime)
model.handleArrival(makePost(createdAt: baseMs, urgent: true))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
#expect(harness.lines.isEmpty)
}
@Test
func simultaneousUrgentPosts_collapseIntoOneLine() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "one", urgent: true))
model.handleArrival(makePost(content: "two", urgent: true))
model.handleArrival(makePost(content: "three", urgent: true))
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].content.contains("3"))
#expect(harness.pendingFlushes.isEmpty)
}
@Test
func urgentPostsInDifferentScopes_alertEachScope() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true))
model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true))
harness.flushAll()
#expect(harness.lines.count == 2)
#expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""])
}
@Test
func duplicateArrival_isHandledOnce() {
let harness = Harness()
let model = makeModel(harness: harness)
let id = Data(repeating: 3, count: 16)
model.handleArrival(makePost(urgent: true, postID: id))
model.handleArrival(makePost(urgent: true, postID: id))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
#expect(harness.lines.count == 1)
#expect(!harness.lines[0].content.contains("2"))
}
@Test
func markSeen_clearsOnlyVisibleScopes() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(geohash: ""))
model.handleArrival(makePost(geohash: "9q8yy"))
model.handleArrival(makePost(geohash: "u4pruyd"))
// Opening the sheet on mesh + 9q8yy must not eat the badge for the
// never-shown u4pruyd channel.
model.markSeen(forScopes: ["", "9q8yy"])
#expect(model.unseenCount(forGeohash: "") == 0)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(model.unseenCount(forGeohash: "u4pruyd") == 1)
}
@Test
func reset_dropsPendingUrgentLinesAndBadges() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "pre-wipe secret", urgent: true))
#expect(harness.pendingFlushes.count == 1)
// Panic wipe lands before the collapse flush fires.
model.reset()
harness.flushAll()
#expect(harness.lines.isEmpty)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
}
@Test
func longUrgentContent_isTruncatedInLine() {
let harness = Harness()
let model = makeModel(harness: harness)
let long = String(repeating: "a", count: 400)
model.handleArrival(makePost(content: long, urgent: true))
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].content.count < 200)
#expect(harness.lines[0].content.contains(""))
}
}
@@ -0,0 +1,142 @@
//
// UnifiedNoticesTests.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
struct UnifiedNoticesTests {
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private func makePost(
content: String,
nickname: String = "alice",
createdAt: UInt64? = nil,
urgent: Bool = false
) -> BoardPostPacket {
BoardPostPacket(
postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
geohash: "9q8yy",
content: content,
authorSigningKey: Data(repeating: 1, count: 32),
authorNickname: nickname,
createdAt: createdAt ?? baseMs,
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
flags: urgent ? BoardPostPacket.urgentFlag : 0,
signature: Data(repeating: 2, count: 64)
)
}
private func makeNote(
content: String,
nickname: String? = "alice",
createdAt: Date? = nil,
geohash: String = "9q8yy"
) -> LocationNotesManager.Note {
LocationNotesManager.Note(
id: UUID().uuidString,
pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
content: content,
createdAt: createdAt ?? baseDate,
nickname: nickname,
geohash: geohash
)
}
@Test
func merge_dropsBridgedCopyOfBoardPost() {
let post = makePost(content: "free couch on 5th")
let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30))
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
#expect(merged.count == 1)
#expect(merged[0].isBoardPost)
}
@Test
func merge_keepsNoteWithSameContentOutsideWindow() {
let post = makePost(content: "water station here")
let oldNote = makeNote(
content: "water station here",
createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60)
)
let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote])
#expect(merged.count == 2)
}
@Test
func merge_keepsSameTextNoteFromNeighborCell() {
// The notes subscription covers the center cell plus 8 neighbors; a
// matching note posted to a *neighbor* is not the bridged copy.
let post = makePost(content: "free couch on 5th")
let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz")
let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote])
#expect(merged.count == 2)
}
@Test
func merge_keepsNoteFromDifferentAuthor() {
let post = makePost(content: "meetup at 6", nickname: "alice")
let note = makeNote(content: "meetup at 6", nickname: "bob")
let merged = UnifiedNotices.merge(posts: [post], notes: [note])
#expect(merged.count == 2)
}
@Test
func merge_sortsUrgentFirstThenNewest() {
let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true)
let newerPost = makePost(content: "later post", createdAt: baseMs)
let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30))
let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note])
#expect(merged.map(\.content) == ["road closed", "a note", "later post"])
#expect(merged[0].isUrgent)
}
@Test
func merge_anonNicknamesMatchForDedupe() {
// Bridged posts from an empty nickname arrive as anon notes with no
// "n" tag; they must still dedupe against the anon board copy.
let post = makePost(content: "hello", nickname: "")
let bridged = makeNote(content: "hello", nickname: nil)
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
#expect(merged.count == 1)
#expect(merged[0].isBoardPost)
#expect(merged[0].author == "anon")
}
@Test
func noticeItem_normalizesNoteDisplayName() {
let note = LocationNotesManager.Note(
id: "e1",
pubkey: "deadbeef",
content: "hi",
createdAt: baseDate,
nickname: "dave",
geohash: "9q8yy"
)
let item = NoticeItem(note: note)
#expect(item.author == "dave")
#expect(!item.isBoardPost)
#expect(!item.isUrgent)
}
}
+39 -9
View File
@@ -1,3 +1,4 @@
import Combine
import Testing
import Foundation
import SwiftUI
@@ -39,6 +40,7 @@ private struct SmokeFeatureModels {
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel
}
@MainActor
@@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
locationChannelsModel: locationChannelsModel
)
let boardAlertsModel = BoardAlertsModel(
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { _ in false },
emitSystemLine: { _, _ in }
)
)
return SmokeFeatureModels(
publicChatModel: publicChatModel,
appChromeModel: appChromeModel,
@@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
privateConversationModel: privateConversationModel,
verificationModel: verificationModel,
conversationUIModel: conversationUIModel,
peerListModel: peerListModel
peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel
)
}
@@ -106,6 +117,7 @@ private func installSmokeEnvironment<V: View>(
.environmentObject(featureModels.verificationModel)
.environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel)
}
@MainActor
@@ -395,9 +407,17 @@ struct ViewSmokeTests {
}
@Test
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
let (viewModel, _, _) = makeSmokeViewModel()
func noticesView_rendersNoRelayAndLoadedStates() throws {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
defer { featureModels.locationChannelsModel.select(.mesh) }
let board = BoardManager(
transport: transport,
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
publishToNostr: { _, _, _, _ in nil },
deleteFromNostr: { _, _ in }
)
let noRelayManager = LocationNotesManager(
geohash: "u4pruydq",
@@ -440,18 +460,28 @@ struct ViewSmokeTests {
eose?()
_ = mount(
LocationNotesView(
geohash: "u4pruydq",
NoticesView(
senderNickname: viewModel.nickname,
manager: noRelayManager
board: board,
initialTab: .geo,
notesManager: noRelayManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationNotesView(
geohash: "u4pruydq",
NoticesView(
senderNickname: viewModel.nickname,
manager: loadedManager
board: board,
initialTab: .geo,
notesManager: loadedManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
NoticesView(
senderNickname: viewModel.nickname,
board: board,
initialTab: .mesh
)
.environmentObject(featureModels.locationChannelsModel)
)