mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
a0c517c018
commit
8415c52913
@@ -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()
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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) + "…"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user