[codex] Refactor app runtime and ownership architecture (#1104)

* Refactor app runtime and view model architecture

* Move app ownership into stores and coordinators

* Fix smoke test environment injection

* Stabilize fragmentation package tests

* Fix coordinator build warnings

* Clean up chat view model warnings

* Fix Nostr relay startup coalescing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-05-30 14:13:26 +02:00
committed by GitHub
co-authored by jack
parent a6cb8872fb
commit 764f016d17
53 changed files with 10717 additions and 6192 deletions
+363
View File
@@ -0,0 +1,363 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
case background
}
enum TorLifecycleEvent: String, Sendable, Equatable {
case willStart
case willRestart
case didBecomeReady
case preferenceChanged
}
enum AppEvent: Sendable, Equatable {
case launched
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
case nostrRelayConnectionChanged(Bool)
case terminationRequested
}
actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
}
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
lhs.id == rhs.id
}
}
extension PeerHandle: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum ConversationID: Hashable, Sendable {
case mesh
case geohash(String)
case direct(PeerHandle)
init(channelID: ChannelID) {
switch channelID {
case .mesh:
self = .mesh
case .location(let channel):
self = .geohash(channel.geohash.lowercased())
}
}
}
@MainActor
final class IdentityResolver {
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
private var handlesByNoiseKey: [String: PeerHandle] = [:]
private var handlesByNostrKey: [String: PeerHandle] = [:]
func register(peers: [BitchatPeer]) {
for peer in peers {
_ = register(peer: peer)
}
}
@discardableResult
func register(peer: BitchatPeer) -> PeerHandle {
let handle = buildHandle(
routingPeerID: peer.peerID,
displayName: peer.displayName,
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
)
cache(handle)
return handle
}
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
if let handle = handlesByRoutingPeerID[peerID] {
return handle
}
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
return handle
}
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
return handle
}
let handle = buildHandle(
routingPeerID: peerID,
displayName: displayName,
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
)
cache(handle)
return handle
}
private func buildHandle(
routingPeerID: PeerID,
displayName: String?,
noisePublicKeyHex: String?,
nostrPublicKey: String?
) -> PeerHandle {
let canonicalID: String
if let noisePublicKeyHex {
canonicalID = "noise:\(noisePublicKeyHex)"
} else if let nostrPublicKey {
canonicalID = "nostr:\(nostrPublicKey)"
} else {
canonicalID = "mesh:\(routingPeerID.id)"
}
let normalizedDisplayName: String?
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalizedDisplayName = displayName
} else {
normalizedDisplayName = nil
}
return PeerHandle(
id: canonicalID,
routingPeerID: routingPeerID,
displayName: normalizedDisplayName,
noisePublicKeyHex: noisePublicKeyHex,
nostrPublicKey: nostrPublicKey
)
}
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
guard let nostrPublicKey else { return nil }
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private func cache(_ handle: PeerHandle) {
handlesByRoutingPeerID[handle.routingPeerID] = handle
if let noisePublicKeyHex = handle.noisePublicKeyHex {
handlesByNoiseKey[noisePublicKeyHex] = handle
}
if let nostrPublicKey = handle.nostrPublicKey {
handlesByNostrKey[nostrPublicKey] = handle
}
}
}
@MainActor
final class ConversationStore: ObservableObject {
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
@Published private(set) var selectedConversationID: ConversationID = .mesh
@Published private(set) var unreadConversations: Set<ConversationID> = []
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID
if selectedPrivatePeerID == nil {
selectedConversationID = ConversationID(channelID: channelID)
}
}
func setSelectedPeerID(
_ peerID: PeerID?,
activeChannel: ChannelID,
identityResolver: IdentityResolver
) {
self.activeChannel = activeChannel
selectedPrivatePeerID = peerID
if let peerID {
selectedConversationID = directConversationID(
for: peerID,
identityResolver: identityResolver
)
} else {
selectedConversationID = ConversationID(channelID: activeChannel)
}
}
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
messagesByConversation[conversationID] = normalized(messages)
}
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replaceMessages(messages, for: ConversationID(channelID: channelID))
}
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
setActiveChannel(activeChannel)
replaceMessages(messages, for: activeChannel)
}
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
messagesByConversation[conversationID] ?? []
}
func directMessages(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> [BitchatMessage] {
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
}
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
for (conversationID, handle) in directHandlesByConversation {
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
}
return messagesByPeerID
}
func unreadDirectPeerIDs() -> Set<PeerID> {
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
guard case .direct(let handle) = conversationID else { return }
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
}
}
func synchronizeSelection(
activeChannel: ChannelID,
selectedPeerID: PeerID?,
identityResolver: IdentityResolver
) {
setSelectedPeerID(
selectedPeerID,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func synchronizePrivateChats(
_ privateChats: [PeerID: [BitchatMessage]],
unreadPeerIDs: Set<PeerID>,
identityResolver: IdentityResolver
) {
var liveConversations = Set<ConversationID>()
for (peerID, messages) in privateChats {
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages)
}
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
guard case .direct = conversationID else { return false }
return !liveConversations.contains(conversationID)
}
for conversationID in staleDirectConversations {
messagesByConversation.removeValue(forKey: conversationID)
unreadConversations.remove(conversationID)
directHandlesByConversation.removeValue(forKey: conversationID)
}
let publicUnread = unreadConversations.filter { conversationID in
switch conversationID {
case .mesh, .geohash:
return true
case .direct:
return false
}
}
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle))
}
}
func markRead(_ conversationID: ConversationID) {
unreadConversations.remove(conversationID)
}
func markRead(
peerID: PeerID,
identityResolver: IdentityResolver
) {
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
}
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
var uniqueMessages: [String: BitchatMessage] = [:]
for message in messages {
uniqueMessages[message.id] = message
}
return uniqueMessages.values.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.id < rhs.id
}
}
private func directConversationID(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> ConversationID {
let handle = identityResolver.canonicalHandle(for: peerID)
let conversationID = ConversationID.direct(handle)
directHandlesByConversation[conversationID] = handle
return conversationID
}
}
+100
View File
@@ -0,0 +1,100 @@
import BitFoundation
import Combine
import CoreBluetooth
import Foundation
@MainActor
final class AppChromeModel: ObservableObject {
@Published private(set) var hasUnreadPrivateMessages = false
@Published var nickname: String
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
}
var shouldSuppressScreenshotNotification: Bool {
isLocationChannelsSheetPresented || isAppInfoPresented
}
func setNickname(_ nickname: String) {
self.nickname = nickname
if chatViewModel.nickname != nickname {
chatViewModel.nickname = nickname
}
}
func validateAndSaveNickname() {
chatViewModel.validateAndSaveNickname()
if nickname != chatViewModel.nickname {
nickname = chatViewModel.nickname
}
}
func openMostRelevantPrivateChat() {
chatViewModel.openMostRelevantPrivateChat()
}
func showFingerprint(for peerID: PeerID) {
showingFingerprintFor = peerID
}
func clearFingerprint() {
showingFingerprintFor = nil
}
func presentAppInfo() {
isAppInfoPresented = true
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
func panicClearAllData() {
chatViewModel.panicClearAllData()
}
private func bind(privateInboxModel: PrivateInboxModel) {
privateInboxModel.$unreadPeerIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] unreadPeerIDs in
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] nickname in
guard let self, self.nickname != nickname else { return }
self.nickname = nickname
}
.store(in: &cancellables)
chatViewModel.$showBluetoothAlert
.receive(on: DispatchQueue.main)
.assign(to: &$showBluetoothAlert)
chatViewModel.$bluetoothAlertMessage
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothAlertMessage)
chatViewModel.$bluetoothState
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothState)
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
}
}
+387
View File
@@ -0,0 +1,387 @@
import BitFoundation
import Combine
import Foundation
import SwiftUI
import Tor
import UserNotifications
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
private var started = false
private var lastNostrRelayConnectedState = false
private var didHandleInitialNostrConnection = false
#if os(iOS)
private var didHandleInitialActive = false
private var didEnterBackground = false
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
self.verificationModel = VerificationModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
peerIdentityStore: peerIdentityStore
)
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard !started else {
checkForSharedContent()
return
}
started = true
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
guard let self else { return }
let nickname = await MainActor.run { self.chatViewModel.nickname }
let npub = await MainActor.run {
try? self.idBridge.getCurrentNostrIdentity()?.npub
}
await MainActor.run {
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
}
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
record(.launched)
record(.startupCompleted)
}
func handleOpenURL(_ url: URL) {
record(.openedURL(url.absoluteString))
if url.scheme == "bitchat", url.host == "share" {
checkForSharedContent()
}
}
func handleDidBecomeActiveNotification() {
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#endif
#if os(iOS)
func handleScenePhaseChange(_ newPhase: ScenePhase) {
switch newPhase {
case .background:
record(.scenePhaseChanged(.background))
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
chatViewModel.endGeohashSampling()
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
TorURLSession.shared.rebuild()
NostrRelayManager.shared.resetAllConnections()
}
}
}
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
case .inactive:
record(.scenePhaseChanged(.inactive))
@unknown default:
break
}
}
#endif
func applicationWillTerminate() {
record(.terminationRequested)
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
}
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
record(.deepLinkOpened(deepLink))
openExternalURL(url)
}
}
func presentationOptions(
forNotificationIdentifier identifier: String,
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
}
if identifier.hasPrefix("geo-activity-"),
let deepLink = userInfo["deeplink"] as? String,
let geohash = deepLink.components(separatedBy: "/").last,
case .location(let channel) = locationChannelsModel.selectedChannel,
channel.geohash == geohash {
return []
}
return [.banner, .sound]
}
}
private extension AppRuntime {
func bindRuntimeObservers() {
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] isConnected in
self?.handleNostrRelayConnectionChanged(isConnected)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
.store(in: &cancellables)
#if os(iOS)
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleScreenshotCaptured()
}
.store(in: &cancellables)
#endif
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
record(.nostrRelayConnectionChanged(isConnected))
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
if !chatViewModel.nostrHandlersSetup {
chatViewModel.setupNostrMessageHandling()
chatViewModel.nostrHandlersSetup = true
}
guard !isInitialConnection else { return }
chatViewModel.resubscribeCurrentGeohash()
chatViewModel.geoChannelCoordinator?.refreshSampling()
}
func announceInitialTorStatusIfNeeded() {
if TorManager.shared.torEnforced &&
!chatViewModel.torStatusAnnounced &&
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
)
}
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
appChromeModel.triggerScreenshotPrivacyWarning()
return
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
func record(_ event: AppEvent) {
Task {
await events.emit(event)
}
}
}
+183
View File
@@ -0,0 +1,183 @@
import BitFoundation
import Combine
import SwiftUI
#if os(iOS)
import UIKit
#endif
@MainActor
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
}
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
chatViewModel.currentColorScheme = colorScheme
}
func sendMessage(_ message: String) {
chatViewModel.sendMessage(message)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
func sendHug(to sender: String) {
chatViewModel.sendMessage("/hug @\(sender)")
}
func sendSlap(to sender: String) {
chatViewModel.sendMessage("/slap @\(sender)")
}
func block(peerID: PeerID?, displayName: String?) {
guard let displayName else { return }
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
message.mediaAttachment(for: currentNickname)
}
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
}
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
}
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
return nickname
}
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
#if os(iOS)
func processSelectedImage(_ image: UIImage?) {
chatViewModel.processThenSendImage(image)
}
#endif
func processSelectedImage(from url: URL?) {
#if os(macOS)
chatViewModel.processThenSendImage(from: url)
#endif
}
func sendVoiceNote(at url: URL) {
chatViewModel.sendVoiceNote(at: url)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
func deleteMediaMessage(messageID: String) {
chatViewModel.deleteMediaMessage(messageID: messageID)
}
private func bind() {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
chatViewModel.$showAutocomplete
.receive(on: DispatchQueue.main)
.assign(to: &$showAutocomplete)
chatViewModel.$autocompleteSuggestions
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshComputedState()
}
.store(in: &cancellables)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshComputedState()
}
.store(in: &cancellables)
}
private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID {
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
return
}
switch activeChannel {
case .mesh:
canSendMediaInCurrentContext = true
case .location:
canSendMediaInCurrentContext = false
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class LocationChannelsModel: ObservableObject {
@Published private(set) var permissionState: LocationChannelManager.PermissionState
@Published private(set) var availableChannels: [GeohashChannel]
@Published private(set) var selectedChannel: ChannelID
@Published private(set) var teleported: Bool
@Published private(set) var bookmarks: [String]
@Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool
private let manager: LocationChannelManager
private let network: NetworkActivationService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil
) {
let manager = manager ?? .shared
let network = network ?? .shared
self.manager = manager
self.network = network
self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel
self.teleported = manager.teleported
self.bookmarks = manager.bookmarks
self.bookmarkNames = manager.bookmarkNames
self.locationNames = manager.locationNames
self.userTorEnabled = network.userTorEnabled
bind()
}
var currentBuildingGeohash: String? {
availableChannels.first(where: { $0.level == .building })?.geohash
}
func isSelected(_ channel: GeohashChannel) -> Bool {
guard case .location(let selected) = selectedChannel else { return false }
return selected == channel
}
func isBookmarked(_ geohash: String) -> Bool {
manager.isBookmarked(geohash)
}
func enableLocationChannels() {
manager.enableLocationChannels()
}
func refreshChannels() {
manager.refreshChannels()
}
func enableAndRefresh() {
manager.enableLocationChannels()
manager.refreshChannels()
}
func beginLiveRefresh() {
manager.beginLiveRefresh()
}
func endLiveRefresh() {
manager.endLiveRefresh()
}
func select(_ channel: ChannelID) {
manager.select(channel)
}
func markTeleported(for geohash: String, _ flag: Bool) {
manager.markTeleported(for: geohash, flag)
}
func toggleBookmark(_ geohash: String) {
manager.toggleBookmark(geohash)
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
manager.resolveBookmarkNameIfNeeded(for: geohash)
}
func locationName(for level: GeohashChannelLevel) -> String? {
locationNames[level]
}
func setUserTorEnabled(_ enabled: Bool) {
network.setUserTorEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
availableChannels.isEmpty else {
return
}
refreshChannels()
}
func openLocationChannel(for geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(normalized.count),
normalized.allSatisfy({ allowed.contains($0) }) else {
return
}
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
let isRegional = availableChannels.contains { $0.geohash == normalized }
if !isRegional && !availableChannels.isEmpty {
markTeleported(for: normalized, true)
}
select(.location(channel))
}
func teleport(to geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
markTeleported(for: normalized, true)
select(.location(channel))
}
private func bind() {
manager.$permissionState
.receive(on: DispatchQueue.main)
.assign(to: &$permissionState)
manager.$availableChannels
.receive(on: DispatchQueue.main)
.assign(to: &$availableChannels)
manager.$selectedChannel
.receive(on: DispatchQueue.main)
.assign(to: &$selectedChannel)
manager.$teleported
.receive(on: DispatchQueue.main)
.assign(to: &$teleported)
manager.$bookmarks
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarks)
manager.$bookmarkNames
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarkNames)
manager.$locationNames
.receive(on: DispatchQueue.main)
.assign(to: &$locationNames)
network.$userTorEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled)
}
private func level(forLength length: Int) -> GeohashChannelLevel {
switch length {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8...12: return .building
default: return .block
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import Combine
import Foundation
@MainActor
final class LocationPresenceStore: ObservableObject {
@Published private(set) var currentGeohash: String?
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
}
func clearGeoNicknames() {
geoNicknames.removeAll()
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
teleportedGeo.removeAll()
}
}
+125
View File
@@ -0,0 +1,125 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PeerIdentityStore: ObservableObject {
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
@Published private(set) var verifiedFingerprints: Set<String> = []
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
private(set) var selectedPrivateChatFingerprint: String?
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
stablePeerIDsByShortID[peerID]
}
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
}
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
if let fingerprint {
peerFingerprintsByPeerID[peerID] = fingerprint
} else {
peerFingerprintsByPeerID.removeValue(forKey: peerID)
}
}
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
peerFingerprintsByPeerID = mappings
}
@discardableResult
func migrateFingerprintMapping(
from oldPeerID: PeerID,
to newPeerID: PeerID,
fallback: String? = nil
) -> String? {
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
peerFingerprintsByPeerID[newPeerID] = fingerprint
if selectedPrivateChatFingerprint == nil {
selectedPrivateChatFingerprint = fingerprint
}
}
return fingerprint
}
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
selectedPrivateChatFingerprint = fingerprint
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatusCache[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
encryptionStatusCache[peerID] = status
}
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
if let peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatuses[peerID]
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
if let status {
encryptionStatuses[peerID] = status
} else {
encryptionStatuses.removeValue(forKey: peerID)
}
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
func setVerified(_ fingerprint: String, verified: Bool) {
if verified {
verifiedFingerprints.insert(fingerprint)
} else {
verifiedFingerprints.remove(fingerprint)
}
}
func isVerified(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func clearAll() {
encryptionStatuses.removeAll()
verifiedFingerprints.removeAll()
peerFingerprintsByPeerID.removeAll()
selectedPrivateChatFingerprint = nil
stablePeerIDsByShortID.removeAll()
encryptionStatusCache.removeAll()
}
}
+260
View File
@@ -0,0 +1,260 @@
import BitFoundation
import Combine
import SwiftUI
struct MeshPeerRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let isMe: Bool
let hasUnread: Bool
let isBlocked: Bool
let isFavorite: Bool
let isConnected: Bool
let isReachable: Bool
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
var id: String { peerID.id }
}
struct GeohashPersonRow: Identifiable, Equatable {
let id: String
let displayName: String
let isMe: Bool
let isTeleported: Bool
let isBlocked: Bool
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
self.allPeers = chatViewModel.allPeers
bind()
refresh()
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
}
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
}
func participantCount(for geohash: String) -> Int {
chatViewModel.geohashParticipantCount(for: geohash)
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}
func openGeohashDirectMessage(with pubkeyHex: String) {
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.blockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.unblockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
private func bind() {
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
self?.allPeers = peers
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationPresenceStore.$teleportedGeo
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
private func refresh() {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isConnected: peer.isConnected,
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge
)
}
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
guard !row.isMe else { return }
if row.isConnected {
counts.connected += 1
counts.reachable += 1
} else if row.isReachable {
counts.reachable += 1
}
}
let geohashPeople = buildGeohashPeople()
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
}
).joined(separator: "|")
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
return chatViewModel.visibleGeohashPeople().map { person in
let isMe = person.id == myHex
return GeohashPersonRow(
id: person.id,
displayName: person.displayName,
isMe: isMe,
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
)
}
}
private func currentGeohashIdentityHex() -> String? {
guard case .location(let channel) = locationChannelsModel.selectedChannel,
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
return nil
}
return identity.publicKeyHex.lowercased()
}
}
+298
View File
@@ -0,0 +1,298 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.conversationStore = conversationStore
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
messagesByPeerID = nextMessagesByPeerID
}
}
enum PrivateConversationAvailability: Equatable {
case bluetoothConnected
case meshReachable
case nostrAvailable
case offline
}
struct PrivateConversationHeaderState: Equatable {
let conversationPeerID: PeerID
let headerPeerID: PeerID
let displayName: String
let availability: PrivateConversationAvailability
let isFavorite: Bool
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM
}
}
@MainActor
final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
bind()
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
refreshSelectedConversation()
}
func openConversation(for peerID: PeerID) {
if peerID.isGeoChat {
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
chatViewModel.startGeohashDM(withPubkeyHex: full)
} else {
chatViewModel.startPrivateChat(with: peerID)
}
refreshSelectedConversation()
}
func endConversation() {
chatViewModel.endPrivateChat()
refreshSelectedConversation()
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
refreshSelectedConversation()
}
func toggleFavoriteForSelectedConversation() {
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
toggleFavorite(peerID: headerPeerID)
}
func markMessagesAsRead(from peerID: PeerID) {
chatViewModel.markPrivateMessagesAsRead(from: peerID)
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
}
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: headerPeerID,
displayName: displayName,
availability: availability,
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
encryptionStatus: encryptionStatus
)
}
private func resolveDisplayName(
for conversationPeerID: PeerID,
headerPeerID: PeerID,
peer: BitchatPeer?
) -> String {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
if let displayName = peer?.displayName {
return displayName
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
for: Data(hexString: headerPeerID.id) ?? Data()
), !favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
if let connectionState = peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
return .bluetoothConnected
case .meshReachable:
return .meshReachable
case .nostrAvailable:
return .nostrAvailable
case .offline:
return .offline
}
}
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
return .meshReachable
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
return .bluetoothConnected
}
return .offline
}
}
+41
View File
@@ -0,0 +1,41 @@
import BitFoundation
import Combine
import SwiftUI
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
}
private func refreshMessages() {
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
}
}
+152
View File
@@ -0,0 +1,152 @@
import BitFoundation
import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
}
enum VerificationScanOutcome: Equatable {
case requested(String)
case notFound
case invalid
}
@MainActor
final class VerificationModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var selectedPeerID: PeerID?
private let chatViewModel: ChatViewModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.currentNickname = chatViewModel.nickname
self.selectedPeerID = privateConversationModel.selectedPeerID
bind(privateConversationModel: privateConversationModel)
}
func myQRString() -> String {
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
}
guard chatViewModel.beginQRVerification(with: qr) else {
return .notFound
}
return .requested(qr.nickname)
}
func verifyFingerprint(for peerID: PeerID) {
chatViewModel.verifyFingerprint(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
chatViewModel.unverifyFingerprint(for: peerID)
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
}
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
)
}
private func bind(privateConversationModel: PrivateConversationModel) {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.assign(to: &$selectedPeerID)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
return name
}
if let data = peerID.noiseKey {
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
+31 -189
View File
@@ -6,9 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Tor
import SwiftUI
import BitFoundation
import UserNotifications
@main
@@ -16,118 +14,47 @@ struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var chatViewModel: ChatViewModel
@StateObject private var runtime: AppRuntime
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Skip the very first .active-triggered Tor restart on cold launch
@State private var didHandleInitialActive: Bool = false
@State private var didEnterBackground: Bool = false
#elseif os(macOS)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
init() {
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
_runtime = StateObject(wrappedValue: AppRuntime())
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(chatViewModel)
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
.environmentObject(runtime.verificationModel)
.environmentObject(runtime.conversationUIModel)
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
appDelegate.chatViewModel = chatViewModel
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content
checkForSharedContent()
appDelegate.runtime = runtime
runtime.start()
}
.onOpenURL { url in
handleURL(url)
runtime.handleOpenURL(url)
}
#if os(iOS)
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
// Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded
Task { @MainActor in
chatViewModel.endGeohashSampling()
}
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
// Restart services when becoming active
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
// On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
// Rebuild proxied sessions to bind to the live Tor after readiness
TorURLSession.shared.rebuild()
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
}
}
checkForSharedContent()
case .inactive:
break
@unknown default:
break
}
runtime.handleScenePhaseChange(newPhase)
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Check for shared content when app becomes active
checkForSharedContent()
runtime.handleDidBecomeActiveNotification()
}
#elseif os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
// App became active
runtime.handleMacDidBecomeActiveNotification()
}
#endif
}
@@ -136,66 +63,18 @@ struct BitchatApp: App {
.windowResizability(.contentSize)
#endif
}
private func handleURL(_ url: URL) {
if url.scheme == "bitchat" && url.host == "share" {
// Handle shared content
checkForSharedContent()
}
}
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
// Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
// No need to force synchronize here
// Send the shared content immediately on the main queue
DispatchQueue.main.async {
if contentType == "url" {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
// Send plain URL
self.chatViewModel.sendMessage(url)
} else {
// Fallback to simple URL
self.chatViewModel.sendMessage(sharedContent)
}
} else {
self.chatViewModel.sendMessage(sharedContent)
}
}
}
}
}
#if os(iOS)
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel?
weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
true
}
func applicationWillTerminate(_ application: UIApplication) {
chatViewModel?.applicationWillTerminate()
runtime?.applicationWillTerminate()
}
}
#endif
@@ -204,44 +83,29 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
import AppKit
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel?
weak var runtime: AppRuntime?
func applicationWillTerminate(_ notification: Notification) {
chatViewModel?.applicationWillTerminate()
runtime?.applicationWillTerminate()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
true
}
}
#endif
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel?
weak var runtime: AppRuntime?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
}
}
}
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler()
}
@@ -249,34 +113,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
// Access main-actor-isolated property via Task
Task { @MainActor in
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([])
} else {
completionHandler([.banner, .sound])
Task {
let options = await self.runtime?.presentationOptions(
forNotificationIdentifier: identifier,
userInfo: userInfo
) ?? [.banner, .sound]
completionHandler(options)
}
}
return
}
}
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases
completionHandler([.banner, .sound])
}
}
+153 -73
View File
@@ -121,13 +121,12 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date?
}
// Default relay list (can be customized)
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
private static let defaultRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub",
"wss://nostr21.com"
"wss://offchain.pub"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays)
@@ -143,10 +142,19 @@ final class NostrRelayManager: ObservableObject {
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// Coalesce duplicate subscribe requests for the same id within a short window
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable {
let messageString: String
let relayURLs: Set<String>
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
@@ -154,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
@@ -233,25 +242,7 @@ final class NostrRelayManager: ObservableObject {
func connect() {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
connectToRelays(relays.map(\.url), shouldLog: true)
}
/// Disconnect from all relays
@@ -264,6 +255,14 @@ final class NostrRelayManager: ObservableObject {
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
subscriptionRequestState.removeAll()
pendingEOSECallbacks.removeAll()
for (_, tracker) in eoseTrackers {
tracker.timer?.invalidate()
}
eoseTrackers.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
updateConnectionStatus()
}
@@ -273,22 +272,12 @@ final class NostrRelayManager: ObservableObject {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.ensureConnections(to: relayUrls) }
}
return
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
for url in targets where connections[url] == nil {
connectToRelay(url)
}
connectToRelays(targets)
}
/// Send an event to specified relays (or all if none specified)
@@ -311,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connections[relayUrl] {
if let connection = connectedConnection(for: relayUrl) {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
@@ -333,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
@@ -348,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
if let conn = connectedConnection(for: url) {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
@@ -362,6 +351,14 @@ final class NostrRelayManager: ObservableObject {
}
}
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
return nil
}
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe(
filter: NostrFilter,
@@ -372,24 +369,12 @@ final class NostrRelayManager: ObservableObject {
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
}
}
return
}
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
@@ -407,10 +392,17 @@ final class NostrRelayManager: ObservableObject {
let baseUrls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
}
subscriptionRequestState[id] = requestState
// Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url })
var existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
existingSet.insert(url)
}
for url in urls {
var map = self.pendingSubscriptions[url] ?? [:]
@@ -421,20 +413,10 @@ final class NostrRelayManager: ObservableObject {
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else if shouldWaitForTorBeforeConnecting {
pendingEOSECallbacks[id] = onEOSE
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
}
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
@@ -506,6 +488,13 @@ final class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers[id]?.timer?.invalidate()
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
}
let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req)
@@ -526,6 +515,100 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady(targets)
return
}
if shouldLog {
let route = shouldUseTor ? "via Tor" : "direct"
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
}
for url in targets {
connectToRelay(url)
}
}
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
pendingTorConnectionURLs.formUnion(targets)
guard !awaitingTorForConnections else { return }
awaitingTorForConnections = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
let pending = Array(self.pendingTorConnectionURLs)
self.pendingTorConnectionURLs.removeAll()
self.awaitingTorForConnections = false
guard ready else {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
self.connectToRelays(pending, shouldLog: true)
}
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id] == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackers[id]?.timer?.invalidate()
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
if let tracker = self.eoseTrackers[id] {
tracker.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
callback()
}
}
}
eoseTrackers[id] = tracker
}
private func startPendingEOSETrackingIfNeeded(id: String) {
guard eoseTrackers[id] == nil,
let callback = pendingEOSECallbacks.removeValue(forKey: id),
let requestState = subscriptionRequestState[id]
else {
return
}
if requestState.relayURLs.isEmpty {
callback()
} else {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
}
}
private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
@@ -550,12 +633,8 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady([urlString])
return
}
@@ -592,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
@@ -671,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
}
}
case .notice:
+3 -2
View File
@@ -28,6 +28,7 @@ struct CommandGeoParticipant {
protocol CommandContextProvider: AnyObject {
// MARK: - State Properties
var nickname: String { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
@@ -75,7 +76,7 @@ final class CommandProcessor {
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch LocationChannelManager.shared.selectedChannel {
switch contextProvider?.activeChannel ?? .mesh {
case .mesh: return false
case .location: return true
}
@@ -135,7 +136,7 @@ final class CommandProcessor {
private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers
switch LocationChannelManager.shared.selectedChannel {
switch contextProvider?.activeChannel ?? .mesh {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = contextProvider else { return .success(message: "nobody around") }
@@ -0,0 +1,92 @@
import BitFoundation
import Foundation
@MainActor
final class ChatComposerCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
let peerCandidates = autocompleteCandidates()
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
for: text,
peers: peerCandidates,
cursorPosition: cursorPosition
)
if !suggestions.isEmpty {
viewModel.autocompleteSuggestions = suggestions
viewModel.autocompleteRange = range
viewModel.showAutocomplete = true
viewModel.selectedAutocompleteIndex = 0
} else {
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.showAutocomplete = false
viewModel.selectedAutocompleteIndex = 0
}
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
guard let range = viewModel.autocompleteRange else { return text.count }
text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range)
viewModel.showAutocomplete = false
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.selectedAutocompleteIndex = 0
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
}
func parseMentions(from content: String) -> [String] {
let regex = ChatViewModel.Patterns.mention
let nsContent = content as NSString
let matches = regex.matches(
in: content,
options: [],
range: NSRange(location: 0, length: nsContent.length)
)
let peerNicknames = viewModel.meshService.getPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(viewModel.nickname)
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
var mentions: [String] = []
for match in matches {
guard let range = Range(match.range(at: 1), in: content) else { continue }
let mentionedName = String(content[range])
if validTokens.contains(mentionedName) {
mentions.append(mentionedName)
}
}
return Array(Set(mentions))
}
}
private extension ChatComposerCoordinator {
func autocompleteCandidates() -> [String] {
switch viewModel.activeChannel {
case .mesh:
let values = viewModel.meshService.getPeerNicknames().values
return Array(values.filter { $0 != viewModel.meshService.myNickname })
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in viewModel.geoNicknames {
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
return Array(tokens)
}
}
}
@@ -0,0 +1,89 @@
import BitFoundation
import BitLogger
import Foundation
final class ChatDeliveryCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
@MainActor
func cleanupOldReadReceipts() {
guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else {
return
}
let validMessageIDs = Set(
viewModel.privateChats.values.flatMap { messages in
messages.map(\.id)
}
)
let oldCount = viewModel.sentReadReceipts.count
viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - viewModel.sentReadReceipts.count
if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
}
@MainActor
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
updateMessageDeliveryStatus(
receipt.originalMessageID,
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
)
}
@MainActor
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
updateMessageDeliveryStatus(messageID, status: status)
}
@MainActor
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
var didUpdateStatus = false
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
let currentStatus = viewModel.messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
viewModel.messages[index].deliveryStatus = status
didUpdateStatus = true
}
}
var privateChats = viewModel.privateChats
for (peerID, chatMessages) in privateChats {
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
let updatedMessages = chatMessages
updatedMessages[index].deliveryStatus = status
privateChats[peerID] = updatedMessages
didUpdateStatus = true
}
if didUpdateStatus {
viewModel.privateChats = privateChats
viewModel.objectWillChange.send()
}
}
}
private extension ChatDeliveryCoordinator {
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let currentStatus else { return false }
switch (currentStatus, newStatus) {
case (.read, .delivered), (.read, .sent):
return true
default:
return false
}
}
}
@@ -0,0 +1,260 @@
import BitFoundation
import BitLogger
import Foundation
@MainActor
final class ChatLifecycleCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func handleDidBecomeActive() {
if let bleService = viewModel.meshService as? BLEService {
let currentState = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(currentState)
}
guard let peerID = viewModel.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let viewModel = self.viewModel
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in
Task { @MainActor in
viewModel?.markPrivateMessagesAsRead(from: peerID)
}
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(viewModel.nickname) took a screenshot *"
if let peerID = viewModel.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
)
appendPrivateScreenshotNotice(for: peerID)
return
}
switch viewModel.activeChannel {
case .mesh:
viewModel.meshService.sendMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date()
)
case .location(let channel):
sendPublicGeohashScreenshotMessage(
screenshotMessage,
channel: channel
)
}
viewModel.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
viewModel.identityManager.forceSave()
_ = viewModel.keychain.verifyIdentityKeyExists()
}
func applicationWillTerminate() {
viewModel.meshService.stopServices()
saveIdentityState()
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
viewModel.privateChatManager.markAsRead(from: peerID)
viewModel.synchronizePrivateConversationStore()
if peerID.isGeoDM,
let recipientHex = viewModel.nostrKeyMapping[peerID],
case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let messages = viewModel.privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
SecureLogger.debug(
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: .session
)
let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
nostrTransport.senderPeerID = viewModel.meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(
message.id,
toRecipientHex: recipientHex,
from: identity
)
viewModel.sentReadReceipts.insert(message.id)
}
return
}
var noiseKeyHex: PeerID?
var peerNostrPubkey: String?
if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) {
viewModel.unreadPrivateMessages.remove(noiseKeyHex)
}
}
guard peerNostrPubkey != nil else { return }
for message in getPrivateChatMessages(for: peerID) {
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
continue
}
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
)
let recipientPeerID = peerID.isHex
? peerID
: (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID)
viewModel.sentReadReceipts.insert(message.id)
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return viewModel.messages }
return getPrivateChatMessages(for: peerID)
}
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
if let ephemeralMessages = viewModel.privateChats[peerID] {
combined.append(contentsOf: ephemeralMessages)
}
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] {
combined.append(contentsOf: stableMessages)
}
}
var bestByID: [String: BitchatMessage] = [:]
for message in combined {
if let existing = bestByID[message.id] {
let existingRank = deliveryStatusRank(existing.deliveryStatus)
let candidateRank = deliveryStatusRank(message.deliveryStatus)
if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) {
bestByID[message.id] = message
}
} else {
bestByID[message.id] = message
}
}
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
}
}
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return }
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
switch sessionState {
case .established:
viewModel.messageRouter.sendPrivate(
message,
to: peerID,
recipientNickname: peerNickname,
messageID: UUID().uuidString
)
case .none, .failed, .handshakeQueued, .handshaking:
SecureLogger.debug(
"Skipping screenshot notification to \(peerID) - no established session",
category: .security
)
}
}
func appendPrivateScreenshotNotice(for peerID: PeerID) {
let notice = BitchatMessage(
sender: "system",
content: "you took a screenshot",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.meshService.peerNickname(peerID: peerID),
senderPeerID: viewModel.meshService.myPeerID
)
var chats = viewModel.privateChats
if chats[peerID] == nil {
chats[peerID] = []
}
chats[peerID]?.append(notice)
viewModel.privateChats = chats
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: viewModel.locationManager.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
viewModel.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
}
}
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
guard let status else { return 0 }
switch status {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .partiallyDelivered: return 4
case .delivered: return 5
case .read: return 6
}
}
}
@@ -0,0 +1,323 @@
import BitFoundation
import BitLogger
import Foundation
#if os(iOS)
import UIKit
#endif
@MainActor
final class ChatMediaTransferCoordinator {
private enum MediaSendError: Error {
case encodingFailed
}
private unowned let viewModel: ChatViewModel
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func sendVoiceNote(at url: URL) {
guard viewModel.canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
viewModel.addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
let message = enqueueMediaMessage(
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
targetPeer: targetPeer
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attributes[.size] as? Int,
fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
let size = (attributes[.size] as? Int) ?? 0
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
return
}
let data = try Data(contentsOf: url)
let packet = BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "audio/mp4",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
}
}
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached { [weak self] in
guard let self else { return }
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached { [weak self] in
guard let self else { return }
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard viewModel.canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
viewModel.addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
var processedURL: URL?
do {
let outputURL = try ImageUtils.processImage(at: sourceURL)
processedURL = outputURL
let data = try Data(contentsOf: outputURL)
guard data.count <= FileTransferLimits.maxImageBytes else {
SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session)
await MainActor.run {
self.viewModel.addSystemMessage("Image is too large to send.")
}
try? FileManager.default.removeItem(at: outputURL)
return
}
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)",
targetPeer: targetPeer
)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run {
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
}
if let processedURL {
try? FileManager.default.removeItem(at: processedURL)
}
}
}
}
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
sender: viewModel.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nicknameForPeer(peerID),
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
var chats = viewModel.privateChats
chats[peerID, default: []].append(message)
viewModel.privateChats = chats
viewModel.trimMessagesIfNeeded()
} else {
let (displayName, senderPeerID) = viewModel.currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
viewModel.trimMessagesIfNeeded()
}
let key = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
viewModel.objectWillChange.send()
return message
}
func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId
}
func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)"
}
func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty {
if queue.first == messageID {
queue.removeFirst()
} else if let index = queue.firstIndex(of: messageID) {
queue.remove(at: index)
}
}
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
}
func handleMediaSendFailure(messageID: String, reason: String) {
viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
}
}
func cleanupLocalFile(forMessage message: BitchatMessage) {
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
let base = try? applicationFilesDirectory(),
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != ".",
safeFilename != ".." else {
return
}
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
guard target.path.hasPrefix(base.path) else { continue }
do {
try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile {
continue
} catch {
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
}
}
}
func cancelMediaSend(messageID: String) {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
viewModel.meshService.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
}
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
}
}
private extension ChatMediaTransferCoordinator {
func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(
at: filesDirectory,
withIntermediateDirectories: true,
attributes: nil
)
return filesDirectory
}
}
@@ -0,0 +1,503 @@
import BitFoundation
import Foundation
import SwiftUI
@MainActor
final class ChatMessageFormatter {
typealias Patterns = MessageFormattingEngine.Patterns
private unowned let viewModel: ChatViewModel
private let meshPalette = MinimalDistancePalette(config: .mesh)
private let nostrPalette = MinimalDistancePalette(config: .nostr)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
return cached.identity
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
return cachedText
}
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender != "system" {
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagRegex = Patterns.hashtag
let mentionRegex = Patterns.mention
let cashuRegex = Patterns.cashu
let bolt11Regex = Patterns.bolt11
let lnurlRegex = Patterns.lnurl
let lightningSchemeRegex = Patterns.lightningScheme
let detector = Patterns.linkDetector
let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashuHint = content.lowercased().contains("cashu")
let hashtagMatches = hasHashtagsHint
? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let mentionMatches = hasMentionsHint
? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let urlMatches = hasURLHint
? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? [])
: []
let cashuMatches = hasCashuHint
? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let lightningMatches = hasLightningHint
? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let bolt11Matches = hasLightningHint
? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let lnurlMatches = hasLightningHint
? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ range: NSRange) -> Bool {
for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 {
return true
}
return false
}
func attachedToMention(_ range: NSRange) -> Bool {
if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex {
var index = content.index(before: swiftRange.lowerBound)
while true {
let character = content[index]
if character.isWhitespace || character.isNewline { break }
if character == "@" { return true }
if index == content.startIndex { break }
index = content.index(before: index)
}
}
return false
}
func isStandaloneHashtag(_ range: NSRange) -> Bool {
guard let swiftRange = Range(range, in: content) else { return false }
if swiftRange.lowerBound == content.startIndex { return true }
let previous = content.index(before: swiftRange.lowerBound)
return content[previous].isWhitespace || content[previous].isNewline
}
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches
where !overlapsMention(match.range(at: 0))
&& !attachedToMention(match.range(at: 0))
&& isStandaloneHashtag(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append((match.range, "url"))
}
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "cashu"))
}
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lightning"))
}
let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ range: NSRange) -> Bool {
for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 {
return true
}
return false
}
for match in bolt11Matches
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "bolt11"))
}
for match in lnurlMatches
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lnurl"))
}
allMatches.sort { $0.range.location < $1.range.location }
var lastEnd = content.startIndex
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
for (range, type) in allMatches {
guard let swiftRange = Range(range, in: content) else { continue }
if lastEnd < swiftRange.lowerBound {
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
}
}
let matchText = String(content[swiftRange])
if type == "mention" {
let (mentionBase, mentionSuffix) = matchText.splitSuffix()
let mySuffix: String? = {
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return String(identity.publicKeyHex.suffix(4))
}
return String(viewModel.meshService.myPeerID.id.prefix(4))
}()
let isMentionToMe: Bool = {
if mentionBase == viewModel.nickname {
if let mySuffix, !mentionSuffix.isEmpty {
return mentionSuffix == "#\(mySuffix)"
}
return mentionSuffix.isEmpty
}
return false
}()
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
let at = "@"
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
result.append(AttributedString(mentionBase).mergingAttributes(mentionStyle))
if !mentionSuffix.isEmpty {
var lightStyle = mentionStyle
lightStyle.foregroundColor = mentionColor.opacity(0.6)
result.append(AttributedString(mentionSuffix).mergingAttributes(lightStyle))
}
} else if type == "hashtag" {
let token = String(matchText.dropFirst()).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
let attachedToMentionToken: Bool = {
if swiftRange.lowerBound > content.startIndex {
var index = content.index(before: swiftRange.lowerBound)
while true {
let character = content[index]
if character.isWhitespace || character.isNewline { break }
if character == "@" { return true }
if index == content.startIndex { break }
index = content.index(before: index)
}
}
return false
}()
let standalone: Bool = {
if swiftRange.lowerBound == content.startIndex { return true }
let previous = content.index(before: swiftRange.lowerBound)
return content[previous].isWhitespace || content[previous].isNewline
}()
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMentionToken && standalone,
let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url
tagStyle.underlineStyle = .single
}
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
} else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" {
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else {
var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
)
if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue
matchStyle.underlineStyle = .single
if let url = URL(string: matchText) {
matchStyle.link = url
}
}
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
}
if lastEnd < swiftRange.upperBound {
lastEnd = swiftRange.upperBound
}
}
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
}
}
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
func isSelfMessage(_ message: BitchatMessage) -> Bool {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
return cached.identity
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
peerColor(for: message, isDark: isDark)
}
func peerURL(for peerID: PeerID) -> URL? {
URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
}
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
getPeerPaletteColor(for: peerID, isDark: isDark)
}
}
private extension ChatMessageFormatter {
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.isGeoChat || spid.isGeoDM {
let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.id.count == 16 {
return getPeerPaletteColor(for: spid, isDark: isDark)
} else {
return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)
}
}
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
}
func meshSeed(for peerID: PeerID) -> String {
if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() {
return "noise:" + full
}
return peerID.id.lowercased()
}
func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
if peerID == viewModel.meshService.myPeerID {
return .orange
}
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
return color
}
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
}
func currentMeshPaletteSeeds() -> [String: String] {
let myID = viewModel.meshService.myPeerID
var seeds: [String: String] = [:]
for peer in viewModel.allPeers where peer.peerID != myID {
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
}
return seeds
}
func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
let myHex = currentGeohashIdentityHex()
if let myHex, pubkeyHexLowercased == myHex {
return .orange
}
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
return color
}
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
}
func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
var seeds: [String: String] = [:]
let excluded = myHex ?? ""
for person in viewModel.visibleGeohashPeople() where person.id != excluded {
seeds[person.id] = "nostr:" + person.id
}
return seeds
}
func currentGeohashIdentityHex() -> String? {
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return identity.publicKeyHex.lowercased()
}
return nil
}
}
@@ -0,0 +1,858 @@
import BitFoundation
import BitLogger
import Foundation
import SwiftUI
import Tor
final class ChatNostrCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
@MainActor
func resubscribeCurrentGeohash() {
guard case .location(let channel) = viewModel.activeChannel else { return }
guard let subID = viewModel.geoSubscriptionID else {
switchLocationChannel(to: viewModel.activeChannel)
return
}
viewModel.participantTracker.startRefreshTimer()
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
channel.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.subscribeNostrEvent(event)
}
}
if let dmSub = viewModel.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
viewModel.geoDmSubscriptionID = nil
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.subscribeGiftWrap(giftWrap, id: identity)
}
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id)
else {
return
}
viewModel.deduplicationService.recordNostrEvent(event.id)
if let gh = viewModel.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey)
}
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
if hasTeleportTag {
let key = event.pubkey.lowercased()
let isSelf: Bool = {
if let gh = viewModel.currentGeohash,
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
return myIdentity.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor [weak viewModel] in
viewModel?.locationPresenceStore.markTeleported(key)
}
}
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
viewModel.handlePublicMessage(message)
if !isBlocked {
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type {
case .privateMessage:
viewModel.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
viewModel.publicMessagePipeline.reset()
viewModel.activeChannel = channel
viewModel.publicMessagePipeline.updateActiveChannel(channel)
viewModel.deduplicationService.clearNostrCaches()
switch channel {
case .mesh:
viewModel.refreshVisibleMessages(from: .mesh)
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
viewModel.participantTracker.stopRefreshTimer()
viewModel.participantTracker.setActiveGeohash(nil)
viewModel.locationPresenceStore.clearTeleportedGeo()
case .location:
viewModel.refreshVisibleMessages(from: channel)
}
if case .location = channel {
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() {
viewModel.addPublicSystemMessage(content)
}
}
if let sub = viewModel.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
viewModel.geoSubscriptionID = nil
}
if let dmSub = viewModel.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
viewModel.geoDmSubscriptionID = nil
}
viewModel.currentGeohash = nil
viewModel.participantTracker.setActiveGeohash(nil)
viewModel.locationPresenceStore.clearGeoNicknames()
guard case .location(let channel) = channel else { return }
viewModel.currentGeohash = channel.geohash
viewModel.participantTracker.setActiveGeohash(channel.geohash)
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
let key = identity.publicKeyHex.lowercased()
if viewModel.locationManager.teleported && hasRegional && !inRegional {
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
} else {
viewModel.locationPresenceStore.clearTeleported(key)
}
}
let subID = "geo-\(channel.geohash)"
viewModel.geoSubscriptionID = subID
viewModel.participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.handleNostrEvent(event)
}
}
subscribeToGeoChat(channel)
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
viewModel.deduplicationService.recordNostrEvent(event.id)
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let isSelf: Bool = {
if let gh = viewModel.currentGeohash,
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag, !isSelf {
let key = event.pubkey.lowercased()
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
}
}
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey)
}
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.handlePublicMessage(message)
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
@MainActor
func subscribeToGeoChat(_ channel: GeohashChannel) {
guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.handleGiftWrap(giftWrap, id: identity)
}
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return
}
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
let channel = context.channel
let event = context.event
let identity = context.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug(
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)",
category: .session
)
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
}
viewModel.deduplicationService.recordNostrEvent(event.id)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
let desired = Set(geohashes)
let current = Set(viewModel.geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
viewModel.geoSamplingSubs.removeValue(forKey: subID)
}
for gh in toAdd {
subscribe(gh)
}
}
@MainActor
func subscribe(_ gh: String) {
let subID = "geo-sample-\(gh)"
viewModel.geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.subscribeNostrEvent(event, gh: gh)
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
let existingCount = viewModel.participantTracker.participantCount(for: gh)
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
guard let content = event.content.trimmedOrNilIfEmpty else { return }
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
}
guard existingCount == 0 else { return }
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
let now = Date()
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.lastGeoNotificationAt[gh] = now
let senderSuffix = String(event.pubkey.suffix(4))
let nick = viewModel.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) {
viewModel.synchronizePublicConversationStore(forGeohash: gh)
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
}
@MainActor
func endGeohashSampling() {
for subID in viewModel.geoSamplingSubs.keys {
NostrRelayManager.shared.unsubscribe(id: subID)
}
viewModel.geoSamplingSubs.removeAll()
}
@MainActor
func setupNostrMessageHandling() {
guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug(
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: .session
)
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.handleNostrMessage(event)
}
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
let currentIdentity: NostrIdentity? = await MainActor.run {
try? viewModel.idBridge.getCurrentNostrIdentity()
}
guard let currentIdentity else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
if content.hasPrefix("verify:") {
return
}
if content.hasPrefix("bitchat1:") {
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: senderPubkey)
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey
switch payload.type {
case .privateMessage:
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
let favorites = FavoritesPersistenceService.shared.favorites.values
var npubToMatch = nostrPubkey
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning(
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
category: .session
)
}
}
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug(
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: .session
)
return nil
}
@MainActor
func sendDeliveryAckViaNostrEmbedded(
_ message: BitchatMessage,
wasReadBefore: Bool,
senderPubkey: String,
key: Data?
) {
if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
}
}
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
if isFavorite {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: senderNickname
)
}
var extractedNostrPubkey: String?
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: extractedNostrPubkey,
peerNickname: senderNickname
)
}
NotificationService.shared.sendLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
return
}
let peerID = PeerID(hexData: noisePublicKey)
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
for person in viewModel.visibleGeohashPeople() where person.displayName == name {
return person.id
}
for (pub, nick) in viewModel.geoNicknames where nick == name {
return pub
}
return nil
}
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
let convKey = PeerID(nostr_: hex)
viewModel.nostrKeyMapping[convKey] = hex
viewModel.startPrivateChat(with: convKey)
}
@MainActor
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
viewModel.nostrKeyMapping[senderID]
}
@MainActor
func geohashDisplayName(for convKey: PeerID) -> String {
guard let full = viewModel.nostrKeyMapping[convKey] else {
return convKey.bare
}
return viewModel.displayNameForNostrPubkey(full)
}
}
private extension ChatNostrCoordinator {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = ChatViewModel.base64URLDecode(encoded),
packetData.count <= maxBytes
else {
return nil
}
return BitchatPacket.from(packetData)
}
}
@@ -0,0 +1,151 @@
import BitFoundation
import BitLogger
import Foundation
@MainActor
final class ChatOutgoingCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
if content.hasPrefix("/") {
Task { @MainActor [weak viewModel] in
viewModel?.handleCommand(content)
}
return
}
if viewModel.selectedPrivateChatPeer != nil {
viewModel.updatePrivateChatPeerIfNeeded()
if let selectedPeer = viewModel.selectedPrivateChatPeer {
viewModel.sendPrivateMessage(content, to: selectedPeer)
}
return
}
let mentions = viewModel.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
appendLocalEcho(preparedMessage.message)
routePublicMessage(
originalContent: content,
mentions: mentions,
geoContext: preparedMessage.geoContext,
messageID: preparedMessage.message.id,
timestamp: preparedMessage.message.timestamp
)
}
}
private extension ChatOutgoingCoordinator {
func preparePublicMessage(
content: String,
trimmed: String,
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = viewModel.nickname
var localSenderPeerID = viewModel.meshService.myPeerID
var messageID: String?
var messageTimestamp = Date()
switch viewModel.activeChannel {
case .mesh:
break
case .location(let channel):
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = viewModel.locationManager.teleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: teleported
)
messageID = event.id
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
geoContext = (
channel: channel,
event: event,
identity: identity,
teleported: teleported
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
viewModel.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
}
}
let message = BitchatMessage(
id: messageID,
sender: displaySender,
content: trimmed,
timestamp: messageTimestamp,
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
return (message, geoContext)
}
func appendLocalEcho(_ message: BitchatMessage) {
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
viewModel.trimMessagesIfNeeded()
}
func routePublicMessage(
originalContent: String,
mentions: [String],
geoContext: ChatViewModel.GeoOutgoingContext?,
messageID: String,
timestamp: Date
) {
switch viewModel.activeChannel {
case .mesh:
viewModel.lastPublicActivityAt["mesh"] = Date()
viewModel.meshService.sendMessage(
originalContent,
mentions: mentions,
messageID: messageID,
timestamp: timestamp
)
case .location(let channel):
viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date()
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
viewModel.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak viewModel] in
viewModel?.sendGeohash(context: geoContext)
}
}
}
}
@@ -0,0 +1,379 @@
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
final class ChatPeerIdentityCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = viewModel.unreadPrivateMessages
.map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
return
}
let recent = viewModel.privateChats
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
.sorted { $0.ts > $1.ts }
if let target = recent.first?.id {
startPrivateChat(with: target)
}
}
@MainActor
func isPeerBlocked(_ peerID: PeerID) -> Bool {
viewModel.unifiedPeerService.isBlocked(peerID)
}
@MainActor
func updatePrivateChatPeerIfNeeded() {
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
return
}
if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID {
migrateChatState(from: oldPeerID, to: currentPeerID)
viewModel.selectedPrivateChatPeer = currentPeerID
} else if viewModel.selectedPrivateChatPeer == nil {
viewModel.selectedPrivateChatPeer = currentPeerID
}
var unread = viewModel.unreadPrivateMessages
unread.remove(currentPeerID)
viewModel.unreadPrivateMessages = unread
}
@MainActor
func startPrivateChat(with peerID: PeerID) {
guard peerID != viewModel.meshService.myPeerID else { return }
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown"
if viewModel.unifiedPeerService.isBlocked(peerID) {
viewModel.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
comment: "System message when starting chat fails because peer is blocked"
),
locale: .current,
peerNickname
)
)
return
}
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
viewModel.addSystemMessage(
String(
format: String(
localized: "system.chat.requires_favorite",
comment: "System message when mutual favorite requirement blocks chat"
),
locale: .current,
peerNickname
)
)
return
}
_ = viewModel.privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: viewModel.sentReadReceipts
)
if !peerID.isGeoDM && !peerID.isGeoChat {
switch viewModel.meshService.getNoiseSessionState(for: peerID) {
case .none, .failed:
viewModel.meshService.triggerHandshake(with: peerID)
case .handshakeQueued, .handshaking, .established:
break
}
} else {
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
viewModel.privateChatManager.syncReadReceiptsForSentMessages(
peerID: peerID,
nickname: viewModel.nickname,
externalReceipts: &viewModel.sentReadReceipts
)
if let fingerprint = getFingerprint(for: peerID) {
viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID)
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
} else {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
}
viewModel.privateChatManager.startChat(with: peerID)
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
viewModel.markPrivateMessagesAsRead(from: peerID)
}
@MainActor
func endPrivateChat() {
viewModel.selectedPrivateChatPeer = nil
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
}
@MainActor
func handlePeerStatusUpdate() {
updatePrivateChatPeerIfNeeded()
}
func handleFavoriteStatusChanged(_ notification: Notification) {
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate,
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
migrateNoiseKeyUpdate(
oldPeerID: PeerID(hexData: oldKey),
newPeerID: PeerID(hexData: peerPublicKey)
)
}
updatePrivateChatPeerIfNeeded()
if let isFavorite = notification.userInfo?["isFavorite"] as? Bool {
let peerID = PeerID(hexData: peerPublicKey)
let action = isFavorite ? "favorited" : "unfavorited"
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
viewModel.addSystemMessage("\(peerNickname) \(action) you")
}
}
}
@MainActor
func updateEncryptionStatusForPeers() {
for peerID in viewModel.connectedPeers {
updateEncryptionStatus(for: peerID)
}
}
@MainActor
func updateEncryptionStatus(for peerID: PeerID) {
let noiseService = viewModel.meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if noiseService.hasSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
} else {
viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID)
}
invalidateEncryptionCache(for: peerID)
}
@MainActor
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) {
return cachedStatus
}
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
let status: EncryptionStatus
switch sessionState {
case .established:
status = verifiedEncryptionStatus(for: peerID)
case .handshaking, .handshakeQueued:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
case .none:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
case .failed:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
}
viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
return status
}
@MainActor
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID)
}
@MainActor
func getFingerprint(for peerID: PeerID) -> String? {
viewModel.unifiedPeerService.getFingerprint(for: peerID)
}
@MainActor
func resolveNickname(for peerID: PeerID) -> String {
guard !peerID.isEmpty else { return "unknown" }
if !peerID.isHex {
return peerID.id
}
if let nickname = viewModel.meshService.getPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let petname = identity.localPetname {
return petname
}
return identity.claimedNickname
}
let prefixLength = min(4, peerID.id.count)
let prefix = String(peerID.id.prefix(prefixLength))
return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)"
}
@MainActor
func getMyFingerprint() -> String {
viewModel.meshService.getNoiseService().getIdentityFingerprint()
}
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
switch viewModel.activeChannel {
case .location:
if nickname.contains("#"),
let person = viewModel.publicConversationCoordinator
.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
viewModel.nostrKeyMapping[conversationKey] = person.id
return conversationKey
}
let base = nickname
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
viewModel.nostrKeyMapping[conversationKey] = pubkey
return conversationKey
}
case .mesh:
break
}
return viewModel.unifiedPeerService.getPeerID(for: nickname)
}
@MainActor
func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID.id),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
return "user"
}
}
private extension ChatPeerIdentityCoordinator {
@MainActor
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint {
return peerID
}
return nil
}
@MainActor
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
if let oldMessages = viewModel.privateChats[oldPeerID] {
var chats = viewModel.privateChats
chats[newPeerID, default: []].append(contentsOf: oldMessages)
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
var seenMessageIDs = Set<String>()
chats[newPeerID] = chats[newPeerID]?.filter { message in
if seenMessageIDs.contains(message.id) {
return false
}
seenMessageIDs.insert(message.id)
return true
}
chats.removeValue(forKey: oldPeerID)
viewModel.privateChats = chats
}
var unread = viewModel.unreadPrivateMessages
if unread.contains(oldPeerID) {
unread.remove(oldPeerID)
unread.insert(newPeerID)
viewModel.unreadPrivateMessages = unread
}
}
@MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if viewModel.selectedPrivateChatPeer == oldPeerID {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if viewModel.privateChats[oldPeerID] != nil {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
migrateChatState(from: oldPeerID, to: newPeerID)
if viewModel.selectedPrivateChatPeer == oldPeerID {
viewModel.selectedPrivateChatPeer = newPeerID
}
if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping(
from: oldPeerID,
to: newPeerID,
fallback: getFingerprint(for: newPeerID)
) {
if viewModel.selectedPrivateChatPeer == newPeerID {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
}
}
}
@MainActor
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
if let nickname = viewModel.meshService.peerNickname(peerID: peerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
return favorite.peerNickname
}
return "Unknown"
}
@MainActor
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fingerprint = getFingerprint(for: peerID),
viewModel.peerIdentityStore.isVerified(fingerprint) {
return .noiseVerified
}
return .noiseSecured
}
}
@@ -0,0 +1,186 @@
import BitFoundation
import BitLogger
import Foundation
final class ChatPeerListCoordinator: @unchecked Sendable {
private unowned let viewModel: ChatViewModel
private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer?
private var networkEmptyTimer: Timer?
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
deinit {
networkResetTimer?.invalidate()
networkEmptyTimer?.invalidate()
}
func didUpdatePeerList(_ peers: [PeerID]) {
Task { @MainActor [weak self] in
self?.handlePeerListUpdate(peers)
}
}
}
private extension ChatPeerListCoordinator {
@MainActor
func handlePeerListUpdate(_ peers: [PeerID]) {
viewModel.isConnected = !peers.isEmpty
cleanupStaleUnreadPeerIDs()
let meshPeers = peers.filter { peerID in
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
}
handleNetworkAvailability(meshPeers)
for peerID in peers {
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
}
viewModel.updateEncryptionStatusForPeers()
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
}
}
@MainActor
func handleNetworkAvailability(_ meshPeers: [PeerID]) {
let meshPeerSet = Set(meshPeers)
if meshPeerSet.isEmpty {
scheduleNetworkEmptyTimer()
return
}
invalidateNetworkEmptyTimer()
let newPeers = meshPeerSet.subtracting(recentlySeenPeers)
guard !newPeers.isEmpty else { return }
let cooldown = TransportConfig.networkNotificationCooldownSeconds
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
recentlySeenPeers.formUnion(newPeers)
lastNetworkNotificationTime = Date()
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
}
scheduleNetworkResetTimer()
}
@MainActor
func cleanupStaleUnreadPeerIDs() {
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
guard !staleIDs.isEmpty else {
viewModel.cleanupOldReadReceipts()
return
}
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
continue
}
idsToRemove.append(staleID)
viewModel.unreadPrivateMessages.remove(staleID)
}
if !idsToRemove.isEmpty {
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
}
viewModel.cleanupOldReadReceipts()
}
@MainActor
func scheduleNetworkResetTimer() {
networkResetTimer?.invalidate()
networkResetTimer = Timer.scheduledTimer(withTimeInterval: networkResetGraceSeconds, repeats: false) { [weak self] _ in
MainActor.assumeIsolated { [weak self] in
self?.handleNetworkResetTimerFired()
}
}
}
@MainActor
func handleNetworkResetTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug(
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
category: .session
)
}
networkResetTimer = nil
}
@MainActor
func scheduleNetworkEmptyTimer() {
guard networkEmptyTimer == nil else { return }
networkEmptyTimer = Timer.scheduledTimer(
withTimeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
repeats: false
) { [weak self] _ in
MainActor.assumeIsolated { [weak self] in
self?.handleNetworkEmptyTimerFired()
}
}
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
}
@MainActor
func invalidateNetworkEmptyTimer() {
guard networkEmptyTimer != nil else { return }
networkEmptyTimer?.invalidate()
networkEmptyTimer = nil
}
@MainActor
func handleNetworkEmptyTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug(
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
category: .session
)
}
networkEmptyTimer = nil
}
}
@@ -0,0 +1,714 @@
import BitFoundation
import BitLogger
import Foundation
@MainActor
final class ChatPrivateConversationCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return }
if viewModel.unifiedPeerService.isBlocked(peerID) {
let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user"
viewModel.addSystemMessage(
String(
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
locale: .current,
nickname
)
)
return
}
if peerID.isGeoDM {
sendGeohashDM(content, to: peerID)
return
}
guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = viewModel.meshService.isPeerConnected(peerID)
let isReachable = viewModel.meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID)
if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname
}
recipientNickname = recipientNickname ?? "user"
let messageID = UUID().uuidString
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: viewModel.meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
viewModel.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send()
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
viewModel.messageRouter.sendPrivate(
content,
to: peerID,
recipientNickname: recipientNickname ?? "user",
messageID: messageID
)
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent
}
} else {
if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[index].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
)
}
let name = recipientNickname ?? "user"
viewModel.addSystemMessage(
String(
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
locale: .current,
name
)
)
}
}
func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let channel) = viewModel.activeChannel else {
viewModel.addSystemMessage(
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
)
return
}
let messageID = UUID().uuidString
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
viewModel.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send()
guard let recipientHex = viewModel.nostrKeyMapping[peerID] else {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
)
}
return
}
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
)
}
viewModel.addSystemMessage(
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
return
}
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
)
}
return
}
SecureLogger.debug(
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session
)
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex,
from: identity,
messageID: messageID
)
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
} catch {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
)
}
}
}
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return
}
if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) {
return
}
let senderName = viewModel.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage(
id: messageId,
sender: senderName,
content: pm.content,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: convKey,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
)
if viewModel.privateChats[convKey] == nil {
viewModel.privateChats[convKey] = []
}
viewModel.privateChats[convKey]?.append(message)
let isViewing = viewModel.selectedPrivateChatPeer == convKey
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
viewModel.unreadPrivateMessages.insert(convKey)
}
if isViewing {
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
}
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
viewModel.objectWillChange.send()
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered(
to: viewModel.displayNameForNostrPubkey(senderPubkey),
at: Date()
)
viewModel.objectWillChange.send()
SecureLogger.info(
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: .session
)
} else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .read(
by: viewModel.displayNameForNostrPubkey(senderPubkey),
at: Date()
)
viewModel.objectWillChange.send()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentGeoDeliveryAcks.insert(messageId)
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentReadReceipts.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentReadReceipts.insert(messageId)
}
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(
messageContent,
from: PeerID(hexData: key),
senderNickname: senderNickname
)
}
return
}
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
return
}
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
var isViewingThisChat = false
if viewModel.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = viewModel.selectedPrivateChatPeer,
let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
}
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
viewModel.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: actualSenderNoiseKey
)
if wasReadBefore {
// No-op.
} else if isViewingThisChat {
handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
senderPubkey: senderPubkey
)
} else {
markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
viewModel.objectWillChange.send()
}
func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
return
}
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
return
}
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = PeerID(hexData: peer.noisePublicKey)
if stableKeyHex != peerID,
let nostrMessages = viewModel.privateChats[stableKeyHex],
!nostrMessages.isEmpty {
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
let existingMessageIds = Set(viewModel.privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) {
viewModel.privateChats[peerID]?.append(nostrMessage)
}
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.info(
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
category: .session
)
}
}
if isDuplicateMessage(message.id, targetPeerID: peerID) {
return
}
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
let isViewing = viewModel.selectedPrivateChatPeer == peerID
if isViewing {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
)
viewModel.meshService.sendReadReceipt(receipt, to: peerID)
viewModel.sentReadReceipts.insert(message.id)
} else {
viewModel.unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
)
}
viewModel.objectWillChange.send()
}
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
if viewModel.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
return true
}
for (_, messages) in viewModel.privateChats where messages.contains(where: { $0.id == messageId }) {
return true
}
return false
}
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if viewModel.privateChats[targetPeerID] == nil {
viewModel.privateChats[targetPeerID] = []
}
if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[targetPeerID]?[idx] = message
} else {
viewModel.privateChats[targetPeerID]?.append(message)
}
viewModel.privateChatManager.sanitizeChat(for: targetPeerID)
}
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
guard let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID
else {
return
}
if viewModel.privateChats[ephemeralPeerID] == nil {
viewModel.privateChats[ephemeralPeerID] = []
}
if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[ephemeralPeerID]?[idx] = message
} else {
viewModel.privateChats[ephemeralPeerID]?.append(message)
}
viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID)
}
func handleViewingThisChat(
_ message: BitchatMessage,
targetPeerID: PeerID,
key: Data?,
senderPubkey: String
) {
viewModel.unreadPrivateMessages.remove(targetPeerID)
if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {
viewModel.unreadPrivateMessages.remove(ephemeralPeerID)
}
guard !viewModel.sentReadReceipts.contains(message.id) else { return }
if let key {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
viewModel.sentReadReceipts.insert(message.id)
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
viewModel.sentReadReceipts.insert(message.id)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
}
func markAsUnreadIfNeeded(
shouldMarkAsUnread: Bool,
targetPeerID: PeerID,
key: Data?,
isRecentMessage: Bool,
senderNickname: String,
messageContent: String
) {
guard shouldMarkAsUnread else { return }
viewModel.unreadPrivateMessages.insert(targetPeerID)
if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID {
viewModel.unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
}
}
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":")
var nostrPubkey: String?
if parts.count > 1 {
nostrPubkey = String(parts[1])
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
}
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
guard let finalNoiseKey = noiseKey else {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
)
if isFavorite && nostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...",
category: .session
)
}
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
viewModel.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
}
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
let isActionMessage = message.content.hasPrefix("* ")
&& message.content.hasSuffix(" *")
&& (message.content.contains("🫂")
|| message.content.contains("🐟")
|| message.content.contains("took a screenshot"))
guard isActionMessage else { return message }
return BitchatMessage(
id: message.id,
sender: "system",
content: String(message.content.dropFirst(2).dropLast(2)),
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
}
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = viewModel.getFingerprint(for: peerID)
if viewModel.privateChats[peerID] == nil || viewModel.privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = []
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID {
let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID]
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
guard !recentMessages.isEmpty else { continue }
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
SecureLogger.info(
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: .session
)
}
SecureLogger.info(
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)",
category: .session
)
} else if currentFingerprint == nil || oldFingerprint == nil {
let isRelevantChat = recentMessages.contains { msg in
(msg.sender == senderNickname && msg.sender != viewModel.nickname)
|| (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname)
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
}
SecureLogger.warning(
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)",
category: .session
)
}
}
}
if !oldPeerIDsToRemove.isEmpty {
let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove {
viewModel.privateChats.removeValue(forKey: oldID)
viewModel.unreadPrivateMessages.remove(oldID)
viewModel.peerIdentityStore.setFingerprint(nil, for: oldID)
}
if needsSelectedUpdate {
viewModel.selectedPrivateChatPeer = peerID
}
}
if !migratedMessages.isEmpty {
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages)
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChatManager.sanitizeChat(for: peerID)
viewModel.objectWillChange.send()
}
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
var noiseKey: Data?
if let hexKey = Data(hexString: peerID.id) {
noiseKey = hexKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
noiseKey = peer.noisePublicKey
}
if viewModel.meshService.isPeerConnected(peerID) {
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey {
viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
} else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
}
}
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
if viewModel.isPeerBlocked(peerID) { return true }
if peerID.isGeoChat || peerID.isGeoDM,
let full = viewModel.nostrKeyMapping[peerID]?.lowercased(),
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) {
return true
}
}
return false
}
}
@@ -0,0 +1,464 @@
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#endif
@MainActor
final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func visibleGeohashPeople() -> [GeoPerson] {
viewModel.participantTracker.getVisiblePeople()
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) }
}
func geohashParticipantCount(for geohash: String) -> Int {
viewModel.participantTracker.participantCount(for: geohash)
}
func displayNameForPubkey(_ pubkeyHex: String) -> String {
displayNameForNostrPubkey(pubkeyHex)
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
let hex = pubkeyHexLowercased.lowercased()
viewModel.identityManager.setNostrBlocked(hex, isBlocked: true)
viewModel.participantTracker.removeParticipant(pubkeyHex: hex)
if let gh = viewModel.currentGeohash {
let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in
guard let senderPeerID = message.senderPeerID,
senderPeerID.isGeoDM || senderPeerID.isGeoChat else {
return false
}
if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() {
return full == hex
}
return false
}
viewModel.timelineStore.removeMessages(in: gh, where: predicate)
synchronizePublicConversationStore(forGeohash: gh)
if case .location = viewModel.activeChannel {
viewModel.messages.removeAll(where: predicate)
}
}
let conversationPeerID = PeerID(nostr_: hex)
if viewModel.privateChats[conversationPeerID] != nil {
var privateChats = viewModel.privateChats
privateChats.removeValue(forKey: conversationPeerID)
viewModel.privateChats = privateChats
var unread = viewModel.unreadPrivateMessages
unread.remove(conversationPeerID)
viewModel.unreadPrivateMessages = unread
}
for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex {
viewModel.nostrKeyMapping.removeValue(forKey: key)
}
addSystemMessage(
String(
format: String(
localized: "system.geohash.blocked",
comment: "System message shown when a user is blocked in geohash chats"
),
locale: .current,
displayName
)
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
addSystemMessage(
String(
format: String(
localized: "system.geohash.unblocked",
comment: "System message shown when a user is unblocked in geohash chats"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
if let geohash = viewModel.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash),
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
return viewModel.nickname + "#" + suffix
}
if let nick = viewModel.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
return nick + "#" + suffix
}
return "anon#\(suffix)"
}
func currentPublicSender() -> (name: String, peerID: PeerID) {
var displaySender = viewModel.nickname
var senderPeerID = viewModel.meshService.myPeerID
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
senderPeerID = PeerID(nostr: identity.publicKeyHex)
}
return (displaySender, senderPeerID)
}
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage?
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = viewModel.messages.remove(at: index)
}
if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) {
removedMessage = removedMessage ?? storeRemoved
synchronizeAllPublicConversationStores()
}
var chats = viewModel.privateChats
for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count {
if filtered.isEmpty {
chats.removeValue(forKey: peerID)
} else {
chats[peerID] = filtered
}
if removedMessage == nil {
removedMessage = items.first(where: { $0.id == messageID })
}
}
}
viewModel.privateChats = chats
if cleanupFile, let removedMessage {
viewModel.cleanupLocalFile(forMessage: removedMessage)
}
viewModel.objectWillChange.send()
}
func initializeConversationStore() {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
synchronizePublicConversationStore(for: viewModel.activeChannel)
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
}
func synchronizePublicConversationStore(for channel: ChannelID) {
let publicMessages = viewModel.timelineStore.messages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: channel)
if channel == viewModel.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
}
}
func synchronizePublicConversationStore(forGeohash geohash: String) {
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let publicMessages = viewModel.timelineStore.messages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased()))
}
func synchronizeAllPublicConversationStores() {
synchronizePublicConversationStore(for: .mesh)
for geohash in viewModel.timelineStore.geohashKeys() {
synchronizePublicConversationStore(forGeohash: geohash)
}
}
func refreshVisibleMessages(from channel: ChannelID? = nil) {
let target = channel ?? viewModel.activeChannel
viewModel.messages = viewModel.timelineStore.messages(for: target)
viewModel.conversationStore.replaceMessages(viewModel.messages, for: target)
if target == viewModel.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
}
}
func clearCurrentPublicTimeline() {
viewModel.messages.removeAll()
viewModel.timelineStore.clear(channel: viewModel.activeChannel)
Task.detached(priority: .utility) {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
let outgoingDirs = [
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
]
for dir in outgoingDirs {
if FileManager.default.fileExists(atPath: dir.path) {
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: nil
)
}
}
} catch {
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
}
}
}
func addSystemMessage(_ content: String, timestamp: Date = Date()) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: timestamp,
isRelay: false
)
viewModel.messages.append(systemMessage)
}
func addMeshOnlySystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
viewModel.timelineStore.append(systemMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
refreshVisibleMessages()
viewModel.trimMessagesIfNeeded()
viewModel.objectWillChange.send()
}
func addPublicSystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel)
refreshVisibleMessages(from: viewModel.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
viewModel.trimMessagesIfNeeded()
viewModel.objectWillChange.send()
}
func addGeohashOnlySystemMessage(_ content: String) {
if case .location = viewModel.activeChannel {
addPublicSystemMessage(content)
} else {
viewModel.timelineStore.queueGeohashSystemMessage(content)
}
}
func sendPublicRaw(_ content: String) {
if case .location(let channel) = viewModel.activeChannel {
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: viewModel.locationManager.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
NostrRelayManager.shared.sendEvent(event)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
} catch {
SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session)
}
}
return
}
viewModel.meshService.sendMessage(
content,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date()
)
}
func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = viewModel.processActionMessage(message)
if viewModel.isMessageBlocked(finalMessage) { return }
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content)
if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) {
return
}
}
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
if !isGeo && finalMessage.sender != "system" {
viewModel.timelineStore.append(finalMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
}
if isGeo && finalMessage.sender != "system",
let geohash = viewModel.currentGeohash,
viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) {
synchronizePublicConversationStore(forGeohash: geohash)
}
let isSystem = finalMessage.sender == "system"
let channelMatches: Bool = {
switch viewModel.activeChannel {
case .mesh: return !isGeo || isSystem
case .location: return isGeo || isSystem
}
}()
guard channelMatches else { return }
if !finalMessage.content.trimmed.isEmpty,
!viewModel.messages.contains(where: { $0.id == finalMessage.id }) {
viewModel.publicMessagePipeline.enqueue(finalMessage)
}
}
func checkForMentions(_ message: BitchatMessage) {
var myTokens: Set<String> = [viewModel.nickname]
let meshPeers = viewModel.meshService.getPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") }
if !collisions.isEmpty {
let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4))
myTokens = [viewModel.nickname + suffix]
}
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
if isMentioned && message.sender != viewModel.nickname {
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
}
}
func sendHapticFeedback(for message: BitchatMessage) {
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
var tokens: [String] = [viewModel.nickname]
switch viewModel.activeChannel {
case .location(let channel):
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
}
case .mesh:
break
}
let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you")
let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around")
let isHugForMe = message.content.contains("🫂") && hugsMe
let isSlapForMe = message.content.contains("🐟") && slapsMe
if isHugForMe && message.sender != viewModel.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.prepare()
for i in 0..<8 {
DispatchQueue.main.asyncAfter(
deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds
) {
impactFeedback.impactOccurred()
}
}
} else if isSlapForMe && message.sender != viewModel.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
impactFeedback.impactOccurred()
}
#endif
}
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
viewModel.messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
viewModel.messages = messages
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
viewModel.deduplicationService.normalizedContentKey(content)
}
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
viewModel.deduplicationService.contentTimestamp(forKey: key)
}
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
viewModel.trimMessagesIfNeeded()
}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
_ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme)
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
viewModel.isBatchingPublic = isBatching
}
}
private extension ChatPublicConversationCoordinator {
func normalizedSenderKey(for message: BitchatMessage) -> String {
if let senderPeerID = message.senderPeerID {
if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
return "nostr:" + full
} else if senderPeerID.id.count == 16,
let full = viewModel.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
return "noise:" + full
} else {
return "mesh:" + senderPeerID.id.lowercased()
}
}
return "name:" + message.sender.lowercased()
}
}
@@ -0,0 +1,280 @@
import BitFoundation
import BitLogger
import Foundation
final class ChatTransportEventCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { viewModel in
guard !viewModel.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
if message.isPrivate {
viewModel.handlePrivateMessage(message)
} else {
viewModel.handlePublicMessage(message)
}
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
func didReceivePublicMessage(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?
) {
runOnMain { viewModel in
let normalized = content.trimmed
let mentions = viewModel.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePublicMessage(message)
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
func didReceiveNoisePayload(
from peerID: PeerID,
type: NoisePayloadType,
payload: Data,
timestamp: Date
) {
runOnMain { [self] viewModel in
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: viewModel
)
}
}
func didConnectToPeer(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.isConnected = true
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
viewModel.objectWillChange.send()
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
viewModel.cacheStablePeerID(stablePeerID, for: peerID)
}
viewModel.messageRouter.flushOutbox(for: peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.identityManager.removeEphemeralSession(peerID: peerID)
var stablePeerID = viewModel.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let derivedPeerID = PeerID(hexData: key)
viewModel.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = viewModel.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: viewModel
)
}
if let messages = viewModel.privateChats[peerID] {
for message in messages where message.senderPeerID == peerID {
viewModel.sentReadReceipts.remove(message.id)
}
}
viewModel.objectWillChange.send()
}
}
}
private extension ChatTransportEventCoordinator {
func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) {
Task { @MainActor [weak viewModel = self.viewModel] in
guard let viewModel else { return }
action(viewModel)
}
}
@MainActor
func migrateSelectedConversationIfNeeded(
from shortPeerID: PeerID,
to stablePeerID: PeerID,
in viewModel: ChatViewModel
) {
if let messages = viewModel.privateChats[shortPeerID] {
if viewModel.privateChats[stablePeerID] == nil {
viewModel.privateChats[stablePeerID] = []
}
let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? [])
for message in messages where !existingIDs.contains(message.id) {
let migrated = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID
? viewModel.meshService.myPeerID
: stablePeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
viewModel.privateChats[stablePeerID]?.append(migrated)
}
viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: shortPeerID)
}
if viewModel.unreadPrivateMessages.contains(shortPeerID) {
viewModel.unreadPrivateMessages.remove(shortPeerID)
viewModel.unreadPrivateMessages.insert(stablePeerID)
}
viewModel.selectedPrivateChatPeer = stablePeerID
}
@MainActor
func handleNoisePayload(
from peerID: PeerID,
type: NoisePayloadType,
payload: Data,
timestamp: Date,
in viewModel: ChatViewModel
) {
switch type {
case .privateMessage:
guard let packet = PrivateMessagePacket.decode(from: payload) else { return }
guard !viewModel.isPeerBlocked(peerID) else {
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
return
}
let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let mentions = viewModel.parseMentions(from: packet.content)
let message = BitchatMessage(
id: packet.messageID,
sender: senderName,
content: packet.content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePrivateMessage(message)
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8),
let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname,
let (foundPeerID, index) = findMessageIndex(
for: messageID,
peerID: peerID,
in: viewModel
) else { return }
if case .read = viewModel.privateChats[foundPeerID]?[index].deliveryStatus { return }
viewModel.privateChats[foundPeerID]?[index].deliveryStatus = .delivered(to: name, at: Date())
viewModel.objectWillChange.send()
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8),
let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname,
let (foundPeerID, index) = findMessageIndex(
for: messageID,
peerID: peerID,
in: viewModel
),
let messages = viewModel.privateChats[foundPeerID],
index < messages.count else { return }
messages[index].deliveryStatus = .read(by: name, at: Date())
viewModel.privateChats[foundPeerID] = messages
viewModel.privateChatManager.objectWillChange.send()
viewModel.objectWillChange.send()
case .verifyChallenge:
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
case .verifyResponse:
viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
}
@MainActor
func findMessageIndex(
for messageID: String,
peerID: PeerID,
in viewModel: ChatViewModel
) -> (peerID: PeerID, index: Int)? {
if let messages = viewModel.privateChats[peerID],
let index = messages.firstIndex(where: { $0.id == messageID }) {
return (peerID, index)
}
if peerID.bare.count == 16,
let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
!peer.noisePublicKey.isEmpty {
let longID = PeerID(hexData: peer.noisePublicKey)
if let messages = viewModel.privateChats[longID],
let index = messages.firstIndex(where: { $0.id == messageID }) {
return (longID, index)
}
}
if peerID.bare.count == 64 {
let shortID = peerID.toShort()
if let messages = viewModel.privateChats[shortID],
let index = messages.firstIndex(where: { $0.id == messageID }) {
return (shortID, index)
}
}
return nil
}
}
@@ -0,0 +1,251 @@
import BitFoundation
import BitLogger
import Foundation
import Security
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
let noiseKeyHex: String
let signKeyHex: String
let nonceA: Data
let startedAt: Date
var sent: Bool
}
private unowned let viewModel: ChatViewModel
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
private var lastMutualToastAt: [String: Date] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func verifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
viewModel.updateEncryptionStatus(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: false)
viewModel.updateEncryptionStatus(for: peerID)
}
func loadVerifiedFingerprints() {
viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints())
let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
.map { $0.prefix(8) }
.joined(separator: ", ")
SecureLogger.info("🔐 Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security)
let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected }
for favorite in offlineFavorites {
let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
SecureLogger.info(
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
category: .security
)
}
viewModel.invalidateEncryptionCache()
viewModel.objectWillChange.send()
}
func setupNoiseCallbacks() {
let noiseService = viewModel.meshService.getNoiseService()
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
if self.viewModel.peerIdentityStore.isVerified(fingerprint) {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID)
}
self.viewModel.invalidateEncryptionCache(for: peerID)
if self.viewModel.cachedStablePeerID(for: peerID) == nil,
let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.viewModel.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.viewModel.meshService.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
}
}
}
noiseService.onHandshakeRequired = { [weak self] peerID in
DispatchQueue.main.async {
guard let self else { return }
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.viewModel.invalidateEncryptionCache(for: peerID)
}
}
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
let targetNoise = qr.noiseKeyHex.lowercased()
guard let peer = viewModel.unifiedPeerService.peers.first(where: {
$0.noisePublicKey.hexEncodedString().lowercased() == targetNoise
}) else {
return false
}
let peerID = peer.peerID
if pendingQRVerifications[peerID] != nil {
return true
}
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
var pending = PendingVerification(
noiseKeyHex: qr.noiseKeyHex,
signKeyHex: qr.signKeyHex,
nonceA: nonce,
startedAt: Date(),
sent: false
)
pendingQRVerifications[peerID] = pending
let noise = viewModel.meshService.getNoiseService()
if noise.hasEstablishedSession(with: peerID) {
viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
} else {
viewModel.meshService.triggerHandshake(with: peerID)
}
return true
}
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
let myNoiseHex = viewModel.meshService
.getNoiseService()
.getStaticPublicKeyData()
.hexEncodedString()
.lowercased()
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
guard lastVerifyNonceByPeer[peerID] != challenge.nonceA else { return }
lastVerifyNonceByPeer[peerID] = challenge.nonceA
if let fingerprint = viewModel.getFingerprint(for: peerID) {
lastInboundVerifyChallengeAt[fingerprint] = Date()
if viewModel.peerIdentityStore.isVerified(fingerprint) {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID),
notificationPrefix: "verify-mutual"
)
}
}
viewModel.meshService.sendVerifyResponse(
to: peerID,
noiseKeyHex: challenge.noiseKeyHex,
nonceA: challenge.nonceA
)
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
guard let response = VerificationService.shared.parseVerifyResponse(payload),
let pending = pendingQRVerifications[peerID],
response.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(),
response.nonceA == pending.nonceA else { return }
let isValid = VerificationService.shared.verifyResponseSignature(
noiseKeyHex: response.noiseKeyHex,
nonceA: response.nonceA,
signature: response.signature,
signerPublicKeyHex: pending.signKeyHex
)
guard isValid else { return }
pendingQRVerifications.removeValue(forKey: peerID)
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
let shortFingerprint = fingerprint.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification(
title: "Verified",
body: "You verified \(peerName)",
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
)
if let challengeTime = lastInboundVerifyChallengeAt[fingerprint],
Date().timeIntervalSince(challengeTime) < 600 {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: peerName,
notificationPrefix: "verify-mutual"
)
}
viewModel.updateEncryptionStatus(for: peerID)
}
}
private extension ChatVerificationCoordinator {
func maybeSendMutualVerificationNotification(
fingerprint: String,
peerID: PeerID,
title: String,
bodyName: String,
notificationPrefix: String
) {
let now = Date()
let lastToast = lastMutualToastAt[fingerprint] ?? .distantPast
guard now.timeIntervalSince(lastToast) > 60 else { return }
lastMutualToastAt[fingerprint] = now
NotificationService.shared.sendLocalNotification(
title: title,
body: "You and \(bodyName) verified each other",
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
import BitFoundation
import BitLogger
import Combine
import Foundation
struct ChatViewModelServiceBundle {
let commandProcessor: CommandProcessor
let messageRouter: MessageRouter
let privateChatManager: PrivateChatManager
let unifiedPeerService: UnifiedPeerService
let autocompleteService: AutocompleteService
let deduplicationService: MessageDeduplicationService
let publicMessagePipeline: PublicMessagePipeline
@MainActor
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
meshService: Transport
) {
let commandProcessor = CommandProcessor(identityManager: identityManager)
let privateChatManager = PrivateChatManager(meshService: meshService)
let unifiedPeerService = UnifiedPeerService(
meshService: meshService,
idBridge: idBridge,
identityManager: identityManager
)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
self.commandProcessor = commandProcessor
self.messageRouter = messageRouter
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService()
self.deduplicationService = MessageDeduplicationService()
self.publicMessagePipeline = PublicMessagePipeline()
}
}
@MainActor
final class ChatViewModelBootstrapper {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
static func loadPersistedReadReceipts(userDefaults: UserDefaults = .standard) -> Set<String> {
guard let data = userDefaults.data(forKey: "sentReadReceipts"),
let receipts = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return Set(receipts)
}
func configure() {
wireServiceGraph()
bindFeatureObjectChanges()
loadPersistedViewState()
configureTransport()
startRuntimeServices()
bindPeerService()
configureNoiseCallbacks()
bindTransferProgress()
configureGeoChannels()
bindTeleportState()
requestNotifications()
registerObservers()
}
}
private extension ChatViewModelBootstrapper {
func wireServiceGraph() {
viewModel.privateChatManager.messageRouter = viewModel.messageRouter
viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel)
}
func bindFeatureObjectChanges() {
viewModel.privateChatManager.objectWillChange
.sink { [weak viewModel] _ in
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$privateChats
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$unreadMessages
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$selectedPeer
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizeConversationSelectionStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.objectWillChange
.sink { [weak viewModel] _ in
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
}
func loadPersistedViewState() {
viewModel.loadNickname()
viewModel.loadVerifiedFingerprints()
}
func configureTransport() {
viewModel.meshService.delegate = viewModel
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
_ = viewModel.getMyFingerprint()
}
viewModel.meshService.setNickname(viewModel.nickname)
}
func startRuntimeServices() {
viewModel.meshService.startServices()
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
let bleService = viewModel.meshService as? BLEService else { return }
let state = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(state)
}
viewModel.nostrRelayManager = NostrRelayManager.shared
viewModel.messageRouter.flushAllOutbox()
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
try? await Task.sleep(
nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)
)
viewModel.isStartupPhase = false
}
}
func bindPeerService() {
viewModel.unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] peers in
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.allPeers = peers
viewModel.identityResolver.register(peers: peers)
var uniquePeers: [PeerID: BitchatPeer] = [:]
for peer in peers {
if uniquePeers[peer.peerID] == nil {
uniquePeers[peer.peerID] = peer
} else {
SecureLogger.warning(
"⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))",
category: .session
)
}
}
viewModel.peerIndex = uniquePeers
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
}
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
}
}
.store(in: &viewModel.cancellables)
}
func configureNoiseCallbacks() {
viewModel.setupNoiseCallbacks()
}
func bindTransferProgress() {
TransferProgressManager.shared.publisher
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] event in
Task { @MainActor [weak viewModel] in
viewModel?.handleTransferEvent(event)
}
}
.store(in: &viewModel.cancellables)
}
func configureGeoChannels() {
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
locationManager: viewModel.locationManager,
onChannelSwitch: { [weak viewModel] channel in
viewModel?.switchLocationChannel(to: channel)
},
beginSampling: { [weak viewModel] geohashes in
viewModel?.beginGeohashSampling(for: geohashes)
},
endSampling: { [weak viewModel] in
viewModel?.endGeohashSampling()
}
)
}
func bindTeleportState() {
viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] isTeleported in
guard let viewModel else { return }
Task { @MainActor [weak viewModel] in
guard let viewModel,
case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
else {
return
}
let key = identity.publicKeyHex.lowercased()
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains {
$0.geohash == channel.geohash
}
if isTeleported && hasRegional && !inRegional {
viewModel.locationPresenceStore.markTeleported(key)
} else {
viewModel.locationPresenceStore.clearTeleported(key)
}
}
}
.store(in: &viewModel.cancellables)
}
func requestNotifications() {
NotificationService.shared.requestAuthorization()
}
func registerObservers() {
NotificationCenter.default.addObserver(
viewModel,
selector: #selector(ChatViewModel.handleFavoriteStatusChanged(_:)),
name: .favoriteStatusChanged,
object: nil
)
NotificationCenter.default.addObserver(
viewModel,
selector: #selector(ChatViewModel.handlePeerStatusUpdate(_:)),
name: Notification.Name("peerStatusUpdated"),
object: nil
)
}
}
@@ -5,846 +5,137 @@
// Geohash and Nostr logic for ChatViewModel
//
import Foundation
import Combine
import BitLogger
import BitFoundation
import SwiftUI
import Tor
import Foundation
extension ChatViewModel {
// MARK: - Geohash Subscription
// Resubscribe to the active geohash channel without clearing timeline
@MainActor
func resubscribeCurrentGeohash() {
guard case .location(let ch) = activeChannel else { return }
guard let subID = geoSubscriptionID else {
// No existing subscription; set it up
switchLocationChannel(to: activeChannel)
return
}
// Ensure participant decay timer is running
participantTracker.startRefreshTimer()
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.subscribeNostrEvent(event)
}
// Resubscribe geohash DMs for this identity
if let dmSub = geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
}
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
self?.subscribeGiftWrap(giftWrap, id: id)
}
}
nostrCoordinator.resubscribeCurrentGeohash()
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id)
else {
return
}
deduplicationService.recordNostrEvent(event.id)
if let gh = currentGeohash,
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick
}
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
})
if hasTeleportTag {
let key = event.pubkey.lowercased()
// Do not mark our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor in
teleportedGeo = teleportedGeo.union([key])
}
}
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
// Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
// BCH-01-012: Check blocking before any notifications
// handlePublicMessage has its own blocking check but returns silently,
// so we must also guard checkForMentions to prevent notification bypass
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
handlePublicMessage(msg)
// Only check mentions and send haptic if sender is not blocked
if !isBlocked {
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
nostrCoordinator.subscribeNostrEvent(event)
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type {
case .privateMessage:
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
break
}
}
// MARK: - Geohash Channel Handling
@MainActor
func switchLocationChannel(to channel: ChannelID) {
// Reset pending public batches to avoid cross-channel bleed
publicMessagePipeline.reset()
activeChannel = channel
publicMessagePipeline.updateActiveChannel(channel)
// Reset deduplication set and optionally hydrate timeline for mesh
deduplicationService.clearNostrCaches()
switch channel {
case .mesh:
refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
participantTracker.stopRefreshTimer()
participantTracker.setActiveGeohash(nil)
teleportedGeo.removeAll()
case .location:
refreshVisibleMessages(from: channel)
}
// If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel {
for content in timelineStore.drainPendingGeohashSystemMessages() {
addPublicSystemMessage(content)
}
}
// Unsubscribe previous
if let sub = geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
geoSubscriptionID = nil
}
if let dmSub = geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
geoDmSubscriptionID = nil
}
currentGeohash = nil
participantTracker.setActiveGeohash(nil)
// Reset nickname cache for geochat participants
geoNicknames.removeAll()
guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash
participantTracker.setActiveGeohash(ch.geohash)
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
} else {
teleportedGeo.remove(key)
}
}
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.handleNostrEvent(event)
}
subscribeToGeoChat(ch)
nostrCoordinator.switchLocationChannel(to: channel)
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Deduplicate
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
deduplicationService.recordNostrEvent(event.id)
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
// Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag {
// Avoid marking our own key from historical events; rely on manager.teleported for self
if !isSelf {
let key = event.pubkey.lowercased()
Task { @MainActor in
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
}
}
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Skip only very recent self-echo from relay; include older self events for hydration
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
}
// Store mapping for geohash DM initiation
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content
// If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
handlePublicMessage(msg)
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
nostrCoordinator.handleNostrEvent(event)
}
@MainActor
func subscribeToGeoChat(_ ch: GeohashChannel) {
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
// pared back logging: subscribe debug only
// Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
self?.handleGiftWrap(giftWrap, id: id)
}
nostrCoordinator.subscribeToGeoChat(ch)
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return
}
deduplicationService.recordNostrEvent(giftWrap.id)
// Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
nostrKeyMapping[convKey] = senderPubkey
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
// Explicitly list other cases so we get compile-time check if a new case is added in the future
case .verifyChallenge, .verifyResponse:
break
}
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
}
@MainActor
func sendGeohash(context: GeoOutgoingContext) {
let ch = context.channel
let event = context.event
let identity = context.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
nostrCoordinator.sendGeohash(context: context)
}
// Track ourselves as active participant
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
deduplicationService.recordNostrEvent(event.id)
}
// MARK: - Sampling
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
// Disable sampling when app is backgrounded (Tor is stopped there)
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
// Determine which to add and which to remove
let desired = Set(geohashes)
let current = Set(geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID)
}
for gh in toAdd {
subscribe(gh)
}
nostrCoordinator.beginGeohashSampling(for: geohashes)
}
@MainActor
func subscribe(_ gh: String) {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
self?.subscribeNostrEvent(event, gh: gh)
}
nostrCoordinator.subscribe(gh)
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let existingCount = participantTracker.participantCount(for: gh)
// Update participants for this specific geohash
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
guard let content = event.content.trimmedOrNilIfEmpty else { return }
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently
guard existingCount == 0 else { return }
// Avoid notifications for old sampled events when launching or (re)subscribing
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
// Foreground-only notifications: app must be active, and not already viewing this geohash
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
let now = Date()
let last = lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
// Compose a short preview
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor in
lastGeoNotificationAt[gh] = now
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
let senderSuffix = String(event.pubkey.suffix(4))
let nick = geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
// Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
}
/// Stop sampling all extra geohashes.
@MainActor
func endGeohashSampling() {
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
geoSamplingSubs.removeAll()
nostrCoordinator.endGeohashSampling()
}
// MARK: - Nostr DM Handling
@MainActor
func setupNostrMessageHandling() {
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
// Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
)
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
self?.handleNostrMessage(event)
}
nostrCoordinator.setupNostrMessageHandling()
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
// Deduplicate messages by ID
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
deduplicationService.recordNostrEvent(giftWrap.id)
// Ensure we're on a background queue for decryption
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
nostrCoordinator.handleNostrMessage(giftWrap)
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
// Handle verification payloads first
if content.hasPrefix("verify:") {
// Ignore verification payloads arriving via Nostr path for now
// Verification should ideally happen over mesh for security binding
return
}
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") {
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
// Map sender by Nostr pubkey to Noise key when possible
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping
await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types
switch payload.type {
case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
await nostrCoordinator.processNostrMessage(giftWrap)
}
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
// Check favorites for this Nostr key
let favorites = FavoritesPersistenceService.shared.favorites.values
var npubToMatch = nostrPubkey
// Convert hex to npub if needed for comparison
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
}
nostrCoordinator.findNoiseKey(for: nostrPubkey)
}
for relationship in favorites {
// Search through favorites for matching Nostr pubkey
if let storedNostrKey = relationship.peerNostrPublicKey {
// Compare against stored key (could be hex or npub)
if storedNostrKey == npubToMatch {
// SecureLogger.debug(" Found Noise key for Nostr sender (npub match)", category: .session)
return relationship.peerNoisePublicKey
}
// Also try comparing raw hex if stored key is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
return nil
}
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
if let _ = key {
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
// check if we have an identity
if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
}
} else if let id = try? idBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", category: .session)
}
// Same for READ receipt if viewing
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
}
} else if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", category: .session)
}
}
@MainActor
func sendDeliveryAckViaNostrEmbedded(
_ message: BitchatMessage,
wasReadBefore: Bool,
senderPubkey: String,
key: Data?
) {
nostrCoordinator.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: key
)
}
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
// Try to find Noise key associated with this Nostr pubkey
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
// Update favorite status
if isFavorite {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: senderNickname
)
} else {
// Only remove if we don't have it set locally
// Logic handled by persistence service usually, here we just update remote state
// Actually for now we just process the notification
}
// Extract Nostr public key if included
var extractedNostrPubkey: String? = nil
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
// Fallback: simple format FAVORITE:TRUE:npub...
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
// If they favorited us and provided their Nostr key, ensure it's stored
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: extractedNostrPubkey,
peerNickname: senderNickname
)
}
// Show notification
NotificationService.shared.sendLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
}
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
// Find peer Nostr key
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
return
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
}
let peerID = PeerID(hexData: noisePublicKey)
// Route via message router
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash)
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match
for p in visibleGeohashPeople() {
if p.displayName == name {
return p.id
}
}
// Also check nickname cache directly
for (pub, nick) in geoNicknames {
if nick == name { return pub }
}
return nil
nostrCoordinator.nostrPubkeyForDisplayName(name)
}
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
let convKey = PeerID(nostr_: hex)
nostrKeyMapping[convKey] = hex
startPrivateChat(with: convKey)
nostrCoordinator.startGeohashDM(withPubkeyHex: hex)
}
@MainActor
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
return nostrKeyMapping[senderID]
nostrCoordinator.fullNostrHex(forSenderPeerID: senderID)
}
@MainActor
func geohashDisplayName(for convKey: PeerID) -> String {
guard let full = nostrKeyMapping[convKey] else {
return convKey.bare
}
return displayNameForNostrPubkey(full)
nostrCoordinator.geohashDisplayName(for: convKey)
}
}
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,8 @@
import SwiftUI
struct CommandSuggestionsView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Binding var messageText: String
@@ -19,8 +19,8 @@ struct CommandSuggestionsView: View {
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationManager.selectedChannel.isLocation
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
command.alias.starts(with: messageText.lowercased())
}
@@ -79,6 +79,11 @@ struct CommandSuggestionsView: View {
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
)
let locationChannelsModel = LocationChannelsModel()
CommandSuggestionsView(
messageText: $messageText,
@@ -86,5 +91,6 @@ struct CommandSuggestionsView: View {
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(viewModel)
.environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel)
}
+18 -10
View File
@@ -11,7 +11,7 @@ import BitFoundation
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@EnvironmentObject private var viewModel: ChatViewModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
@State private var expandedMessageIDs: Set<String> = []
@@ -24,13 +24,13 @@ struct TextMessageView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
@@ -69,6 +69,20 @@ struct TextMessageView: View {
#Preview {
let keychain = PreviewKeychainManager()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: viewModel.conversationStore
)
Group {
List {
@@ -87,11 +101,5 @@ struct TextMessageView: View {
}
.environment(\.colorScheme, .dark)
}
.environmentObject(
ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
)
.environmentObject(conversationUIModel)
}
+248
View File
@@ -0,0 +1,248 @@
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Environment(\.colorScheme) private var colorScheme
@Binding var messageText: String
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
}
CommandSuggestionsView(
messageText: $messageText,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor
)
if voiceRecordingVM.state.isActive {
recordingIndicator
}
HStack(alignment: .center, spacing: 4) {
TextField(
"",
text: $messageText,
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(secondaryTextColor.opacity(0.6))
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 15, design: .monospaced))
.foregroundColor(textColor)
.focused(isTextFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.sentences)
#endif
.submitLabel(.send)
.onSubmit(onSendMessage)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
.modifier(FocusEffectDisabledModifier())
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in
autocompleteDebounceTimer?.invalidate()
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
let cursorPosition = newValue.count
Task { @MainActor in
conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
}
}
HStack(alignment: .center, spacing: 4) {
if conversationUIModel.canSendMediaInCurrentContext {
attachmentButton
}
sendOrMicButton
}
}
}
.padding(.horizontal, 6)
.padding(.top, 6)
.padding(.bottom, 8)
.background(backgroundColor.opacity(0.95))
.onDisappear {
autocompleteDebounceTimer?.invalidate()
}
}
}
private extension ContentComposerView {
var recordingIndicator: some View {
HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill")
.foregroundColor(.red)
.font(.bitchatSystem(size: 20))
TimelineView(.periodic(from: .now, by: 0.05)) { context in
Text(
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator"
)
.font(.bitchatSystem(size: 13, design: .monospaced))
.foregroundColor(.red)
}
Spacer()
Button(action: voiceRecordingVM.cancel) {
Label("Cancel", systemImage: "xmark.circle")
.labelStyle(.iconOnly)
.font(.bitchatSystem(size: 18))
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.red.opacity(0.15))
)
}
var composerAccentColor: Color {
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor
}
var attachmentButton: some View {
#if os(iOS)
Image(systemName: "camera.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.onTapGesture {
imagePickerSourceType = .photoLibrary
showImagePicker = true
}
.onLongPressGesture(minimumDuration: 0.3) {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel("Choose photo")
#endif
}
@ViewBuilder
var sendOrMicButton: some View {
let hasText = !messageText.trimmed.isEmpty
if conversationUIModel.canSendMediaInCurrentContext {
ZStack {
micButtonView
.opacity(hasText ? 0 : 1)
.allowsHitTesting(!hasText)
sendButtonView(enabled: hasText)
.opacity(hasText ? 1 : 0)
.allowsHitTesting(hasText)
}
.frame(width: 36, height: 36)
} else {
sendButtonView(enabled: hasText)
.frame(width: 36, height: 36)
}
}
var micButtonView: some View {
Image(systemName: "mic.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.overlay(
Color.clear
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
.onEnded { _ in
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
}
)
)
.accessibilityLabel("Hold to record a voice note")
}
func sendButtonView(enabled: Bool) -> some View {
let activeColor = composerAccentColor
return Button(action: onSendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(enabled ? activeColor : Color.gray)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
)
.accessibilityHint(
enabled
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
)
}
}
+315
View File
@@ -0,0 +1,315 @@
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
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
@Binding var showLocationNotes: Bool
@Binding var notesGeohash: String?
var isNicknameFieldFocused: FocusState<Bool>.Binding
let headerHeight: CGFloat
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.onTapGesture(count: 3) {
appChromeModel.panicClearAllData()
}
.onTapGesture(count: 1) {
appChromeModel.presentAppInfo()
}
HStack(spacing: 0) {
Text(verbatim: "@")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
TextField(
"content.input.nickname_placeholder",
text: Binding(
get: { appChromeModel.nickname },
set: { appChromeModel.setNickname($0) }
)
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.frame(maxWidth: 80)
.foregroundColor(textColor)
.focused(isNicknameFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.modifier(FocusEffectDisabledModifier())
.onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in
if !isFocused {
appChromeModel.validateAndSaveNickname()
}
}
.onSubmit {
appChromeModel.validateAndSaveNickname()
}
}
Spacer()
let countAndColor = channelPeopleCountAndColor()
let headerCountColor = countAndColor.1
let headerOtherPeersCount: Int = {
if case .location = locationChannelsModel.selectedChannel {
return peerListModel.visibleGeohashPeerCount
}
return countAndColor.0
}()
HStack(spacing: 10) {
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
)
}
if case .mesh = locationChannelsModel.selectedChannel,
locationChannelsModel.permissionState == .authorized {
Button(action: {
locationChannelsModel.enableAndRefresh()
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
)
}
if case .location(let channel) = locationChannelsModel.selectedChannel {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
locale: .current,
channel.geohash
)
)
}
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
let badgeText: String = {
switch locationChannelsModel.selectedChannel {
case .mesh: return "#mesh"
case .location(let channel): return "#\(channel.geohash)"
}
}()
let badgeColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location:
return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
}()
Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(badgeColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.accessibilityLabel(
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
)
}
.buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
}
.layoutPriority(3)
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
}
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
Group {
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
LocationNotesView(
geohash: geohash,
senderNickname: appChromeModel.nickname
)
.environmentObject(locationChannelsModel)
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
)
.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()
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.onChange(of: locationChannelsModel.permissionState) { _ in
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
Button("common.ok", role: .cancel) {}
} message: {
Text("content.alert.screenshot.message")
}
.background(backgroundColor.opacity(0.95))
}
}
private extension ContentHeaderView {
var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (count, count > 0 ? standardGreen : Color.secondary)
case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
return (peerListModel.reachableMeshPeerCount, color)
}
}
}
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
}
}
+517
View File
@@ -0,0 +1,517 @@
import BitFoundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct ContentPeopleSheetView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Binding var showSidebar: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var isAtBottomPrivate: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
NavigationStack {
Group {
if privateConversationModel.selectedPeerID != nil {
#if os(iOS)
ContentPrivateChatSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentPrivateChatSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
} else {
ContentPeopleListView(
showSidebar: $showSidebar,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight
)
}
}
.navigationDestination(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { isPresented in
if !isPresented {
appChromeModel.clearFingerprint()
}
}
)) {
if let peerID = appChromeModel.showingFingerprintFor {
FingerprintView(peerID: peerID)
.environmentObject(verificationModel)
}
}
}
.background(backgroundColor)
.foregroundColor(textColor)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false
conversationUIModel.processSelectedImage(image)
}
.ignoresSafeArea()
}
#endif
#if os(macOS)
.sheet(isPresented: $showMacImagePicker) {
MacImagePickerView { url in
showMacImagePicker = false
conversationUIModel.processSelectedImage(from: url)
}
}
#endif
}
}
private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@Binding var showSidebar: Bool
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
@State private var showVerifySheet = false
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced))
.foregroundColor(textColor)
Spacer()
if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.help(
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss()
showSidebar = false
showVerifySheet = false
privateConversationModel.endConversation()
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
let activeText = String.localizedStringWithFormat(
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
"\(peopleSheetActiveCount)"
)
if let subtitle = peopleSheetSubtitle {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color.blue
case .location:
return Color.green
}
}()
HStack(spacing: 6) {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(.secondary)
}
.font(.bitchatSystem(size: 12, design: .monospaced))
} else {
Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
},
onToggleFavorite: { peerID in
peerListModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID)
}
)
}
}
.padding(.top, 4)
.id(peerListModel.renderID)
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
}
}
}
private extension ContentPeopleListView {
var peopleSheetTitle: String {
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
}
var peopleSheetSubtitle: String? {
switch locationChannelsModel.selectedChannel {
case .mesh:
return "#mesh"
case .location(let channel):
return "#\(channel.geohash.lowercased())"
}
}
var peopleSheetActiveCount: Int {
switch locationChannelsModel.selectedChannel {
case .mesh:
return peerListModel.reachableMeshPeerCount
case .location:
return peerListModel.visibleGeohashPeerCount
}
}
}
private struct ContentPrivateChatSheetView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Binding var showSidebar: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var isAtBottomPrivate: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
VStack(spacing: 0) {
if let headerState = privateConversationModel.selectedHeaderState {
HStack(spacing: 12) {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
}
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
)
Spacer(minLength: 0)
HStack(spacing: 8) {
ContentPrivateHeaderInfoButton(
headerState: headerState,
headerHeight: headerHeight,
textColor: textColor
)
if headerState.supportsFavoriteToggle {
Button(action: {
privateConversationModel.toggleFavoriteForSelectedConversation()
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel(
headerState.isFavorite
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
}
}
.frame(maxWidth: .infinity)
Spacer(minLength: 0)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(backgroundColor)
}
MessageListView(
privatePeer: privateConversationModel.selectedPeerID,
isAtBottom: $isAtBottomPrivate,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused
)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
#if os(iOS)
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
.background(backgroundColor)
.foregroundColor(textColor)
.highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in
let horizontal = value.translation.width
let vertical = abs(value.translation.height)
guard horizontal > 80, vertical < 60 else { return }
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
privateConversationModel.endConversation()
}
}
)
}
}
private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat
let textColor: Color
var body: some View {
Button(action: {
appChromeModel.showFingerprint(for: headerState.headerPeerID)
}) {
HStack(spacing: 6) {
switch headerState.availability {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
EmptyView()
}
Text(headerState.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? textColor
: Color.red
)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
locale: .current,
encryptionStatus.accessibilityDescription
)
)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
locale: .current,
headerState.displayName
)
)
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(height: headerHeight)
}
}
File diff suppressed because it is too large Load Diff
+19 -39
View File
@@ -10,7 +10,7 @@ import SwiftUI
import BitFoundation
struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel
@EnvironmentObject private var verificationModel: VerificationModel
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@@ -47,6 +47,8 @@ struct FingerprintView: View {
}
var body: some View {
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
VStack(spacing: 20) {
// Header
HStack {
@@ -65,38 +67,19 @@ struct FingerprintView: View {
.padding()
VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if let data = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return Strings.unknownPeer()
}()
// Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
HStack {
if let icon = encryptionStatus.icon {
if let icon = fingerprintState.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 20))
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
.foregroundColor(fingerprintState.encryptionStatus == .noiseVerified ? Color.green : textColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(peerNickname)
Text(fingerprintState.peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
Text(encryptionStatus.description)
Text(fingerprintState.encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
}
@@ -113,7 +96,7 @@ struct FingerprintView: View {
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
if let fingerprint = fingerprintState.theirFingerprint {
Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
@@ -148,8 +131,7 @@ struct FingerprintView: View {
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
let myFingerprint = viewModel.getMyFingerprint()
Text(formatFingerprint(myFingerprint))
Text(formatFingerprint(fingerprintState.myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
@@ -162,30 +144,28 @@ struct FingerprintView: View {
.contextMenu {
Button(Strings.copy) {
#if os(iOS)
UIPasteboard.general.string = myFingerprint
UIPasteboard.general.string = fingerprintState.myFingerprint
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(myFingerprint, forType: .string)
NSPasteboard.general.setString(fingerprintState.myFingerprint, forType: .string)
#endif
}
}
}
// Verification status
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
let isVerified = encryptionStatus == .noiseVerified
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isVerified ? Color.green : Color.orange)
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
Group {
if isVerified {
if fingerprintState.isVerified {
Text(Strings.verifiedMessage)
} else {
Text(Strings.verifyHint(peerNickname))
Text(Strings.verifyHint(fingerprintState.peerNickname))
}
}
.font(.bitchatSystem(size: 12, design: .monospaced))
@@ -195,9 +175,9 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
if !isVerified {
if !fingerprintState.isVerified {
Button(action: {
viewModel.verifyFingerprint(for: peerID)
verificationModel.verifyFingerprint(for: peerID)
dismiss()
}) {
Text(Strings.markVerified)
@@ -211,7 +191,7 @@ struct FingerprintView: View {
.buttonStyle(PlainButtonStyle())
} else {
Button(action: {
viewModel.unverifyFingerprint(for: peerID)
verificationModel.unverifyFingerprint(for: peerID)
dismiss()
}) {
Text(Strings.removeVerification)
+33 -38
View File
@@ -1,7 +1,7 @@
import SwiftUI
struct GeohashPeopleList: View {
@ObservedObject var viewModel: ChatViewModel
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
let onTapPerson: () -> Void
@@ -17,7 +17,7 @@ struct GeohashPeopleList: View {
}
var body: some View {
if viewModel.visibleGeohashPeople().isEmpty {
if peerListModel.geohashPeople.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
@@ -26,26 +26,16 @@ struct GeohashPeopleList: View {
.padding(.top, 12)
}
} else {
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id }
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false
}
let people = peerListModel.geohashPeople
let currentIDs = people.map(\.id)
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) }
let tele = displayIDs.filter { isTeleportedID($0) }
let nonTele = displayIDs.filter { id in
!(people.first(where: { $0.id == id })?.isTeleported ?? false)
}
let tele = displayIDs.filter { id in
people.first(where: { $0.id == id })?.isTeleported ?? false
}
let finalOrder: [String] = nonTele + tele
let firstID = finalOrder.first
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
@@ -54,39 +44,35 @@ struct GeohashPeopleList: View {
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
let person = personByID[pid]!
HStack(spacing: 4) {
let isMe = (person.id == myHex)
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
if isMe {
if person.isMe {
Text(Strings.youSuffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(rowColor)
}
}
if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
if person.isBlocked {
Image(systemName: "nosign")
.font(.bitchatSystem(size: 10))
.foregroundColor(.red)
.help(Strings.blockedTooltip)
}
}
Spacer()
}
.padding(.horizontal)
@@ -94,20 +80,29 @@ struct GeohashPeopleList: View {
.padding(.top, person.id == firstID ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture {
if person.id != myHex {
viewModel.startGeohashDM(withPubkeyHex: person.id)
if !person.isMe {
peerListModel.openGeohashDirectMessage(with: person.id)
onTapPerson()
}
}
.contextMenu {
if let me = myHex, person.id == me {
if person.isMe {
EmptyView()
} else {
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
if blocked {
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
if person.isBlocked {
Button(Strings.unblock) {
peerListModel.unblockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
}
} else {
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
Button(Strings.block) {
peerListModel.blockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
}
}
}
}
+42 -61
View File
@@ -7,10 +7,8 @@ import AppKit
#endif
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = ""
@State private var customError: String? = nil
@@ -108,9 +106,9 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary)
Group {
switch manager.permissionState {
case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) {
switch locationChannelsModel.permissionState {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(standardGreen)
@@ -120,7 +118,7 @@ struct LocationChannelsSheet: View {
.cornerRadius(6)
}
.buttonStyle(.plain)
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
@@ -128,7 +126,7 @@ struct LocationChannelsSheet: View {
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
case LocationChannelManager.PermissionState.authorized:
case .authorized:
EmptyView()
}
}
@@ -152,22 +150,22 @@ struct LocationChannelsSheet: View {
.background(backgroundColor)
.onAppear {
// Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
manager.refreshChannels()
if locationChannelsModel.permissionState == .authorized {
locationChannelsModel.refreshChannels()
}
// Begin periodic refresh while sheet is open
manager.beginLiveRefresh()
locationChannelsModel.beginLiveRefresh()
// Geohash sampling is now managed by ChatViewModel globally
}
.onDisappear {
manager.endLiveRefresh()
locationChannelsModel.endLiveRefresh()
}
.onChange(of: manager.permissionState) { newValue in
if newValue == LocationChannelManager.PermissionState.authorized {
manager.refreshChannels()
.onChange(of: locationChannelsModel.permissionState) { newValue in
if newValue == .authorized {
locationChannelsModel.refreshChannels()
}
}
.onChange(of: manager.availableChannels) { _ in }
.onChange(of: locationChannelsModel.availableChannels) { _ in }
}
private var closeButton: some View {
@@ -183,20 +181,20 @@ struct LocationChannelsSheet: View {
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh)
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
locationChannelsModel.select(ChannelID.mesh)
isPresented = false
}
.padding(.vertical, 6)
let nearby = manager.availableChannels.filter { $0.level != .building }
let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building }
if !nearby.isEmpty {
ForEach(nearby) { channel in
sectionDivider
let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
let participantCount = peerListModel.participantCount(for: channel.geohash)
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
let highlight = participantCount > 0
channelRow(
@@ -206,16 +204,16 @@ struct LocationChannelsSheet: View {
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel))
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
@@ -235,14 +233,14 @@ struct LocationChannelsSheet: View {
customTeleportSection
.padding(.vertical, 8)
let bookmarkedList = bookmarks.bookmarks
let bookmarkedList = locationChannelsModel.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
bookmarkedSection(bookmarkedList)
.padding(.vertical, 8)
}
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
if locationChannelsModel.permissionState == .authorized {
sectionDivider
torToggleSection
.padding(.top, 12)
@@ -309,10 +307,7 @@ struct LocationChannelsSheet: View {
Button(action: {
let gh = normalized
guard isValid else { customError = Strings.invalidGeohash; return }
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
manager.markTeleported(for: ch.geohash, true)
manager.select(ChannelID.location(ch))
locationChannelsModel.teleport(to: gh)
isPresented = false
}) {
HStack(spacing: 6) {
@@ -350,33 +345,33 @@ struct LocationChannelsSheet: View {
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
let name = bookmarks.bookmarkNames[gh]
let participantCount = viewModel.geohashParticipantCount(for: gh)
let name = locationChannelsModel.bookmarkNames[gh]
let participantCount = peerListModel.participantCount(for: gh)
channelRow(
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
isSelected: locationChannelsModel.isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
Button(action: { locationChannelsModel.toggleBookmark(gh) }) {
Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
locationChannelsModel.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
locationChannelsModel.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
.onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
if index < entries.count - 1 {
sectionDivider
@@ -388,14 +383,11 @@ struct LocationChannelsSheet: View {
private func isSelected(_ channel: GeohashChannel) -> Bool {
if case .location(let ch) = manager.selectedChannel {
return ch == channel
}
return false
locationChannelsModel.isSelected(channel)
}
private var isMeshSelected: Bool {
if case .mesh = manager.selectedChannel { return true }
if case .mesh = locationChannelsModel.selectedChannel { return true }
return false
}
@@ -453,17 +445,6 @@ struct LocationChannelsSheet: View {
let suffix = String(s[idx...])
return (prefix, suffix)
}
// MARK: - Helpers for counts
private func meshCount() -> Int {
// Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID
return viewModel.allPeers.reduce(0) { acc, peer in
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
return acc
}
}
private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false }
@@ -487,8 +468,8 @@ struct LocationChannelsSheet: View {
extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { network.userTorEnabled },
set: { network.setUserTorEnabled($0) }
get: { locationChannelsModel.userTorEnabled },
set: { locationChannelsModel.setUserTorEnabled($0) }
)
}
@@ -608,7 +589,7 @@ extension LocationChannelsSheet {
}
private func locationName(for level: GeohashChannelLevel) -> String? {
manager.locationNames[level]
locationChannelsModel.locationName(for: level)
}
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
+7 -5
View File
@@ -1,24 +1,26 @@
import SwiftUI
struct LocationNotesView: View {
@EnvironmentObject var viewModel: ChatViewModel
@StateObject private var manager: LocationNotesManager
let geohash: String
let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject private var locationManager = LocationChannelManager.shared
@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))
}
@@ -113,11 +115,11 @@ struct LocationNotesView: View {
Spacer()
closeButton
}
if let building = locationManager.locationNames[.building], !building.isEmpty {
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
@@ -265,7 +267,7 @@ struct LocationNotesView: View {
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname)
manager.send(content: content, nickname: senderNickname)
draft = ""
}
+7 -8
View File
@@ -10,8 +10,7 @@ import BitFoundation
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
@@ -19,15 +18,15 @@ struct MediaMessageView: View {
var body: some View {
let state = mediaSendState(for: message)
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && message.sender == viewModel.nickname,
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
@@ -55,7 +54,7 @@ struct MediaMessageView: View {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
@@ -81,7 +80,7 @@ struct MediaMessageView: View {
break
}
}
let canCancel = isSending && message.sender == viewModel.nickname
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
let clamped = progress.map { max(0, min(1, $0)) }
return (isSending, isSending ? clamped : nil, canCancel)
}
+17 -29
View File
@@ -2,7 +2,7 @@ import SwiftUI
import BitFoundation
struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
let onTapPeer: (PeerID) -> Void
@@ -19,21 +19,13 @@ struct MeshPeerList: View {
}
var body: some View {
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
return (peer, isMe, hasUnread, enc)
}
// Stable visual order without mutating state here
let currentIDs = mapped.map { $0.peer.peerID.id }
let currentIDs = peerListModel.meshRows.map(\.id)
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
mapped.first(where: { $0.peer.peerID.id == id })
let peers: [MeshPeerRow] = displayIDs.compactMap { id in
peerListModel.meshRows.first(where: { $0.id == id })
}
if viewModel.allPeers.isEmpty {
if peerListModel.meshRows.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
@@ -44,11 +36,10 @@ struct MeshPeerList: View {
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx]
let peer = item.peer
let isMe = item.isMe
let peer = peers[idx]
let isMe = peer.isMe
HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
let assigned = peerListModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
if isMe {
Image(systemName: "person.fill")
@@ -76,8 +67,7 @@ struct MeshPeerList: View {
.foregroundColor(secondaryTextColor)
}
let displayName = isMe ? viewModel.nickname : peer.nickname
let (base, suffix) = displayName.splitSuffix()
let (base, suffix) = peer.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
@@ -90,7 +80,7 @@ struct MeshPeerList: View {
}
}
if !isMe, viewModel.isPeerBlocked(peer.peerID) {
if peer.isBlocked {
Image(systemName: "nosign")
.font(.bitchatSystem(size: 10))
.foregroundColor(.red)
@@ -99,19 +89,18 @@ struct MeshPeerList: View {
if !isMe {
if peer.isConnected {
if let icon = item.enc.icon {
if let icon = peer.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
}
} else {
// Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.peerID),
viewModel.verifiedFingerprints.contains(fp) {
if peer.showsVerifiedBadgeWhenOffline {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
} else if let icon = item.enc.icon {
} else if let icon = peer.encryptionStatus.icon {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
@@ -123,7 +112,7 @@ struct MeshPeerList: View {
Spacer()
// Unread message indicator for this peer
if !isMe, item.hasUnread {
if peer.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
@@ -132,9 +121,9 @@ struct MeshPeerList: View {
if !isMe {
Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
.foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor)
}
.buttonStyle(.plain)
}
@@ -149,10 +138,9 @@ struct MeshPeerList: View {
}
// Seed and update order outside result builder
.onAppear {
let currentIDs = mapped.map { $0.peer.peerID.id }
orderedIDs = currentIDs
}
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
.onChange(of: currentIDs) { ids in
var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
+52 -70
View File
@@ -14,8 +14,11 @@ private struct MessageDisplayItem: Identifiable {
}
struct MessageListView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.colorScheme) private var colorScheme
@@ -43,14 +46,14 @@ struct MessageListView: View {
return windowCountPublic
}()
let messages = viewModel.getMessages(for: privatePeer)
let messages = conversationMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
locationChannelsModel.selectedChannel.contextKey
}
}()
@@ -106,12 +109,12 @@ struct MessageListView: View {
.padding(.vertical, 1)
}
}
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
viewModel.sendMessage("/clear")
conversationUIModel.clearCurrentConversation()
}
.onAppear {
scrollToBottom(on: proxy)
@@ -119,13 +122,13 @@ struct MessageListView: View {
.onChange(of: privatePeer) { _ in
scrollToBottom(on: proxy)
}
.onChange(of: viewModel.messages.count) { _ in
.onChange(of: publicChatModel.messages.count) { _ in
onMessagesChange(proxy: proxy)
}
.onChange(of: viewModel.privateChats) { _ in
.onChange(of: privateMessageCount(for: privatePeer)) { _ in
onPrivateChatsChange(proxy: proxy)
}
.onChange(of: locationManager.selectedChannel) { newChannel in
.onChange(of: locationChannelsModel.selectedChannel) { newChannel in
onSelectedChannelChange(newChannel, proxy: proxy)
}
.confirmationDialog(
@@ -143,13 +146,7 @@ struct MessageListView: View {
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
}
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
@@ -158,25 +155,18 @@ struct MessageListView: View {
Button("content.actions.hug") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/hug @\(sender)")
conversationUIModel.sendHug(to: sender)
}
}
Button("content.actions.slap") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/slap @\(sender)")
conversationUIModel.sendSlap(to: sender)
}
}
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
}
Button("common.cancel", role: .cancel) {}
@@ -185,14 +175,14 @@ struct MessageListView: View {
// Also check when view appears
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID)
privateConversationModel.markMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
privateConversationModel.markMessagesAsRead(from: peerID)
}
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
privateConversationModel.markMessagesAsRead(from: peerID)
}
}
}
@@ -222,7 +212,7 @@ private extension MessageListView {
Group {
if message.sender == "system" {
systemMessageRow(message)
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
} else if let media = conversationUIModel.mediaAttachment(for: message) {
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else {
TextMessageView(message: message)
@@ -232,7 +222,7 @@ private extension MessageListView {
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -246,7 +236,7 @@ private extension MessageListView {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
locationChannelsModel.selectedChannel.contextKey
}
}()
let preserveID = "\(contextKey)|\(message.id)"
@@ -278,15 +268,12 @@ private extension MessageListView {
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
selectedMessageSender = conversationUIModel.senderDisplayName(
for: peerID,
fallbackMessages: conversationMessages(for: privatePeer)
)
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
if conversationUIModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
@@ -297,26 +284,7 @@ private extension MessageListView {
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(channel))
locationChannelsModel.openLocationChannel(for: gh)
default:
return
@@ -337,20 +305,21 @@ private extension MessageListView {
var targetPeerID: String? {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
let last = privateInboxModel.messages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
if let last = viewModel.messages.suffix(300).last?.id {
return "\(locationManager.selectedChannel.contextKey)|\(last)"
if let last = publicChatModel.messages.suffix(300).last?.id {
return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)"
}
return nil
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
let messages = publicChatModel.messages
guard privatePeer == nil, let lastMsg = messages.last else { return }
// If the newest message is from me, always scroll to bottom
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
@@ -359,8 +328,8 @@ private extension MessageListView {
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = locationManager.selectedChannel.contextKey
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
let contextKey = locationChannelsModel.selectedChannel.contextKey
if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
@@ -382,12 +351,14 @@ private extension MessageListView {
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
guard let peerID = privatePeer,
let lastMsg = privateInboxModel.messages(for: peerID).last else {
return
}
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else {
@@ -428,11 +399,22 @@ private extension MessageListView {
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
if let target = publicChatModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
func conversationMessages(for privatePeer: PeerID?) -> [BitchatMessage] {
if let privatePeer {
return privateInboxModel.messages(for: privatePeer)
}
return publicChatModel.messages
}
func privateMessageCount(for privatePeer: PeerID?) -> Int {
conversationMessages(for: privatePeer).count
}
}
private extension ChannelID {
+18 -29
View File
@@ -107,7 +107,7 @@ struct ImageWrapper: View {
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject var viewModel: ChatViewModel
@EnvironmentObject private var verificationModel: VerificationModel
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@@ -135,17 +135,15 @@ struct QRScanView: View {
// Deduplicate: ignore if we just processed this exact QR code
guard code != lastValid else { return }
if let qr = VerificationService.shared.verifyScannedQR(code) {
let ok = viewModel.beginQRVerification(with: qr)
if ok {
switch verificationModel.verifyScannedPayload(code) {
case .requested:
// Successfully initiated verification; remember this QR to prevent re-scanning
lastValid = code
// Close scanner and return to "My QR" view
onSuccess?()
}
// If !ok, peer not found or already pending - don't set lastValid so user can retry
} else {
// ignore invalid reads; continue scanning
case .notFound, .invalid:
// Ignore invalid/no-match reads and keep scanning
break
}
}
.frame(height: 260)
@@ -163,17 +161,15 @@ struct QRScanView: View {
return
}
if let qr = VerificationService.shared.verifyScannedQR(input) {
let ok = viewModel.beginQRVerification(with: qr)
if ok {
result = Strings.requested(qr.nickname)
switch verificationModel.verifyScannedPayload(input) {
case .requested(let nickname):
result = Strings.requested(nickname)
lastValid = input
// Close scanner and return to "My QR" view
onSuccess?()
} else {
case .notFound:
result = Strings.notFound
}
} else {
case .invalid:
result = Strings.invalid
}
}
@@ -282,7 +278,7 @@ struct CameraScannerView: UIViewRepresentable {
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject var viewModel: ChatViewModel
@EnvironmentObject private var verificationModel: VerificationModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
@@ -291,11 +287,6 @@ struct VerificationSheetView: View {
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String {
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
}
var body: some View {
VStack(spacing: 0) {
// Top header (always at top)
@@ -333,14 +324,14 @@ struct VerificationSheetView: View {
QRScanView(isActive: showingScanner, onSuccess: {
showingScanner = false
})
.environmentObject(viewModel)
.environmentObject(verificationModel)
.frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView(onSuccess: {
showingScanner = false
})
.environmentObject(viewModel)
.environmentObject(verificationModel)
#endif
}
.padding()
@@ -348,8 +339,7 @@ struct VerificationSheetView: View {
.background(boxColor)
.cornerRadius(8)
} else {
let qr = myQRString()
MyQRView(qrString: qr)
MyQRView(qrString: verificationModel.myQRString())
}
}
.padding(16)
@@ -373,10 +363,9 @@ struct VerificationSheetView: View {
}
// Optional: Remove verification for selected peer (if verified)
if let pid = viewModel.selectedPrivateChatPeer,
let fp = viewModel.getFingerprint(for: pid),
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
if let peerID = verificationModel.selectedPeerID,
verificationModel.isVerified(peerID: peerID) {
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced))
}
+699
View File
@@ -0,0 +1,699 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@MainActor
private func makeArchitectureViewModel(
locationManager: LocationChannelManager? = nil
) -> ChatViewModel {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let locationManager = locationManager ?? makeArchitectureLocationManager()
return ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: MockTransport(),
locationManager: locationManager
)
}
@MainActor
private func makeArchitectureLocationManager() -> LocationChannelManager {
let suiteName = "AppArchitectureTests.\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName) ?? .standard
storage.removePersistentDomain(forName: suiteName)
return LocationChannelManager(storage: storage)
}
private func makeArchitectureSnapshot(
peerID: PeerID,
nickname: String,
connected: Bool,
noisePublicKey: Data
) -> TransportPeerSnapshot {
TransportPeerSnapshot(
peerID: peerID,
nickname: nickname,
isConnected: connected,
noisePublicKey: noisePublicKey,
lastSeen: Date()
)
}
@MainActor
private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000,
pollNanoseconds: UInt64 = 20_000_000,
_ condition: @escaping @MainActor () -> Bool
) async {
let timeout = Double(timeoutNanoseconds) / 1_000_000_000
let deadline = Date().addingTimeInterval(timeout)
while !condition(), Date() < deadline {
try? await Task.sleep(nanoseconds: pollNanoseconds)
}
}
@Suite("App Architecture Tests", .serialized)
struct AppArchitectureTests {
@Test("PeerIdentityStore owns fingerprint, mapping, and verification state")
@MainActor
func peerIdentityStoreOwnsIdentityState() {
let store = PeerIdentityStore()
let shortPeerID = PeerID(str: "peer-short")
let stablePeerID = PeerID(str: "peer-stable")
let canonicalPeerID = PeerID(str: "peer-canonical")
store.setStablePeerID(stablePeerID, forShortID: shortPeerID)
store.setFingerprint("fp-1", for: shortPeerID)
store.setCachedEncryptionStatus(.noiseHandshaking, for: shortPeerID)
store.setEncryptionStatus(.noiseSecured, for: shortPeerID)
store.setVerified("fp-1", verified: true)
let migratedFingerprint = store.migrateFingerprintMapping(
from: shortPeerID,
to: canonicalPeerID
)
#expect(store.stablePeerID(forShortID: shortPeerID) == stablePeerID)
#expect(store.shortPeerID(forStablePeerID: stablePeerID) == shortPeerID)
#expect(migratedFingerprint == "fp-1")
#expect(store.fingerprint(for: shortPeerID) == nil)
#expect(store.fingerprint(for: canonicalPeerID) == "fp-1")
#expect(store.selectedPrivateChatFingerprint == "fp-1")
#expect(store.encryptionStatus(for: shortPeerID) == .noiseSecured)
#expect(store.cachedEncryptionStatus(for: shortPeerID) == nil)
#expect(store.isVerified("fp-1"))
store.clearAll()
#expect(store.encryptionStatuses.isEmpty)
#expect(store.verifiedFingerprints.isEmpty)
#expect(store.peerFingerprintsByPeerID.isEmpty)
#expect(store.selectedPrivateChatFingerprint == nil)
#expect(store.stablePeerID(forShortID: shortPeerID) == nil)
}
@Test("LocationPresenceStore normalizes and resets geohash presence state")
@MainActor
func locationPresenceStoreNormalizesPresenceState() {
let store = LocationPresenceStore()
store.setCurrentGeohash("U4PRUY")
store.replaceGeoNicknames([
"ABCDEF": "alice",
"123456": "bob"
])
store.markTeleported("ABCDEF")
store.replaceTeleportedGeo(Set(["FEDCBA", "123456"]))
#expect(store.currentGeohash == "u4pruy")
#expect(store.geoNicknames["abcdef"] == "alice")
#expect(store.geoNicknames["123456"] == "bob")
#expect(store.teleportedGeo == Set(["fedcba", "123456"]))
store.reset()
#expect(store.currentGeohash == nil)
#expect(store.geoNicknames.isEmpty)
#expect(store.teleportedGeo.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-a"),
displayName: "alice",
noisePublicKeyHex: "abc123",
nostrPublicKey: nil
)
let second = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-b"),
displayName: "alice-renamed",
noisePublicKeyHex: nil,
nostrPublicKey: "npub123"
)
#expect(first == second)
#expect(Set([first, second]).count == 1)
}
@Test("ConversationStore normalizes timeline ordering and duplicates")
@MainActor
func conversationStoreNormalizesMessages() {
let store = ConversationStore()
let older = BitchatMessage(
id: "m1",
sender: "alice",
content: "first",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let newer = BitchatMessage(
id: "m2",
sender: "alice",
content: "second",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let replacement = BitchatMessage(
id: "m2",
sender: "alice",
content: "second-updated",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
let messages = store.messages(for: ConversationID.mesh)
#expect(messages.map(\.id) == ["m1", "m2"])
#expect(messages.last?.content == "second-updated")
}
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
@MainActor
func conversationStoreTracksUnreadDirectConversations() {
let store = ConversationStore()
let resolver = IdentityResolver()
let peerID = PeerID(str: "peer-1")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "bob",
senderPeerID: peerID
)
store.synchronizePrivateChats(
[peerID: [message]],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
let conversationID = ConversationID.direct(
resolver.canonicalHandle(for: peerID, displayName: "alice")
)
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
#expect(store.unreadConversations.contains(conversationID))
store.markRead(conversationID)
#expect(!store.unreadConversations.contains(conversationID))
}
@Test("ConversationStore tracks the selected app conversation context")
@MainActor
func conversationStoreTracksSelectedConversationContext() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
let peer = BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
resolver.register(peers: [peer])
store.synchronizeSelection(
activeChannel: geohashChannel,
selectedPeerID: shortPeerID,
identityResolver: resolver
)
let expectedConversationID = ConversationID.direct(
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
)
#expect(store.activeChannel == geohashChannel)
#expect(store.selectedPrivatePeerID == shortPeerID)
#expect(store.selectedConversationID == expectedConversationID)
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: nil,
identityResolver: resolver
)
#expect(store.activeChannel == ChannelID.mesh)
#expect(store.selectedPrivatePeerID == nil)
#expect(store.selectedConversationID == ConversationID.mesh)
}
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
@MainActor
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
let firstMessage = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "short id",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: shortPeerID
)
let secondMessage = BitchatMessage(
id: "dm-2",
sender: "alice",
content: "full id",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: fullPeerID
)
resolver.register(
peer: BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[shortPeerID: [firstMessage]],
unreadPeerIDs: Set([shortPeerID]),
identityResolver: resolver
)
resolver.register(
peer: BitchatPeer(
peerID: fullPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[fullPeerID: [secondMessage]],
unreadPeerIDs: Set([fullPeerID]),
identityResolver: resolver
)
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
}
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
@MainActor
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
let store = ConversationStore()
let resolver = IdentityResolver()
let inboxModel = PrivateInboxModel(conversationStore: store)
let messagePeerID = PeerID(str: "peer-1")
let unreadOnlyPeerID = PeerID(str: "peer-2")
let selectedOnlyPeerID = PeerID(str: "peer-3")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: messagePeerID
)
store.synchronizePrivateChats(
[messagePeerID: [message]],
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
identityResolver: resolver
)
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: selectedOnlyPeerID,
identityResolver: resolver
)
await waitUntil {
inboxModel.selectedPeerID == selectedOnlyPeerID &&
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
}
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
#expect(inboxModel.messages(for: unreadOnlyPeerID).isEmpty)
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
}
@Test("AppChromeModel mirrors nickname and unread state through focused models")
@MainActor
func appChromeModelMirrorsNicknameAndUnreadState() async {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let resolver = IdentityResolver()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
chromeModel.setNickname("builder")
await waitUntil {
viewModel.nickname == "builder" && chromeModel.nickname == "builder"
}
#expect(viewModel.nickname == "builder")
#expect(chromeModel.nickname == "builder")
#expect(!chromeModel.hasUnreadPrivateMessages)
let peerID = PeerID(str: "peer-1")
conversationStore.synchronizePrivateChats(
[:],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
await waitUntil {
chromeModel.hasUnreadPrivateMessages
}
#expect(chromeModel.hasUnreadPrivateMessages)
}
@Test("AppChromeModel owns fingerprint and screenshot presentation state")
@MainActor
func appChromeModelOwnsPresentationState() {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
let peerID = PeerID(str: "peer-2")
chromeModel.showFingerprint(for: peerID)
chromeModel.presentAppInfo()
chromeModel.isLocationChannelsSheetPresented = true
chromeModel.triggerScreenshotPrivacyWarning()
#expect(chromeModel.showingFingerprintFor == peerID)
#expect(chromeModel.isAppInfoPresented)
#expect(chromeModel.shouldSuppressScreenshotNotification)
#expect(chromeModel.showScreenshotPrivacyWarning)
chromeModel.clearFingerprint()
#expect(chromeModel.showingFingerprintFor == nil)
}
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
@MainActor
func privateConversationModelResolvesSelectedHeaderState() async {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let conversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
locationChannelsModel: locationChannelsModel
)
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
transport.peerNicknames[shortPeerID] = "alice"
transport.reachablePeers.insert(shortPeerID)
viewModel.allPeers = [
BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: false,
isReachable: true
)
]
conversationModel.startConversation(with: fullPeerID)
await waitUntil {
conversationModel.selectedPeerID == fullPeerID
}
#expect(conversationModel.selectedPeerID == fullPeerID)
#expect(conversationModel.selectedHeaderState?.headerPeerID == shortPeerID)
#expect(conversationModel.selectedHeaderState?.displayName == "alice")
#expect(conversationModel.selectedHeaderState?.availability == .meshReachable)
#expect(conversationModel.selectedHeaderState?.encryptionStatus == .noHandshake)
conversationModel.endConversation()
await waitUntil {
conversationModel.selectedPeerID == nil
}
#expect(conversationModel.selectedPeerID == nil)
#expect(conversationModel.selectedHeaderState == nil)
}
@Test("ConversationUIModel mirrors composer state and forwards sends")
@MainActor
func conversationUIModelMirrorsComposerStateAndForwardsSends() async {
let locationManager = makeArchitectureLocationManager()
let viewModel = makeArchitectureViewModel(locationManager: locationManager)
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let conversationStore = viewModel.conversationStore
locationManager.select(.mesh)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
locationChannelsModel: locationChannelsModel
)
let uiModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: conversationStore
)
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
defer {
locationManager.select(.mesh)
}
viewModel.nickname = "builder"
viewModel.autocompleteSuggestions = ["alice"]
viewModel.showAutocomplete = true
locationChannelsModel.select(geohashChannel)
await waitUntil {
viewModel.activeChannel == geohashChannel &&
uiModel.currentNickname == "builder" &&
uiModel.showAutocomplete &&
uiModel.autocompleteSuggestions == ["alice"] &&
!uiModel.canSendMediaInCurrentContext
}
#expect(viewModel.activeChannel == geohashChannel)
#expect(uiModel.currentNickname == "builder")
#expect(uiModel.showAutocomplete)
#expect(uiModel.autocompleteSuggestions == ["alice"])
#expect(!uiModel.canSendMediaInCurrentContext)
locationChannelsModel.select(ChannelID.mesh)
await waitUntil {
viewModel.activeChannel == ChannelID.mesh &&
uiModel.canSendMediaInCurrentContext
}
#expect(viewModel.activeChannel == ChannelID.mesh)
#expect(uiModel.canSendMediaInCurrentContext)
uiModel.sendMessage("hello mesh")
await waitUntil {
transport.sentMessages.last?.content == "hello mesh"
}
#expect(transport.sentMessages.last?.content == "hello mesh")
}
@Test("VerificationModel bridges selected conversation and fingerprint actions")
@MainActor
func verificationModelBridgesSelectedConversationAndFingerprintActions() async {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let peerID = PeerID(str: "0011223344556677")
let fingerprint = "verified-fingerprint"
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel
)
transport.peerFingerprints[peerID] = fingerprint
transport.peerNicknames[peerID] = "alice"
viewModel.allPeers = [
BitchatPeer(
peerID: peerID,
noisePublicKey: Data((0..<32).map(UInt8.init)),
nickname: "alice",
isConnected: true,
isReachable: true
)
]
privateConversationModel.startConversation(with: peerID)
await waitUntil {
verificationModel.selectedPeerID == peerID
}
let presentation = verificationModel.fingerprintPresentation(for: peerID)
#expect(verificationModel.selectedPeerID == peerID)
#expect(presentation.peerNickname == "alice")
#expect(presentation.theirFingerprint == fingerprint)
#expect(!presentation.myFingerprint.isEmpty)
#expect(!verificationModel.isVerified(peerID: peerID))
verificationModel.verifyFingerprint(for: peerID)
await waitUntil {
verificationModel.isVerified(peerID: peerID)
}
#expect(verificationModel.isVerified(peerID: peerID))
verificationModel.unverifyFingerprint(for: peerID)
await waitUntil {
!verificationModel.isVerified(peerID: peerID)
}
#expect(!verificationModel.isVerified(peerID: peerID))
}
@Test("PeerListModel publishes mesh and geohash directory state")
@MainActor
func peerListModelPublishesDirectoryState() async {
let viewModel = makeArchitectureViewModel()
guard let transport = viewModel.meshService as? MockTransport else {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let myPeerID = PeerID(str: "me-peer")
let otherPeerID = PeerID(str: "0011223344556677")
let geohash = "9q8yy"
let remoteGeoID = String(repeating: "b", count: 64)
let locationManager = makeArchitectureLocationManager()
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let otherNoiseKey = Data((0..<32).map(UInt8.init))
let verifiedFingerprint = otherNoiseKey.sha256Fingerprint()
transport.myPeerID = myPeerID
transport.peerFingerprints[otherPeerID] = verifiedFingerprint
transport.peerNicknames[otherPeerID] = "alice"
transport.reachablePeers.insert(otherPeerID)
viewModel.nickname = "builder"
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
viewModel.unreadPrivateMessages = Set([otherPeerID])
transport.updatePeerSnapshots([
makeArchitectureSnapshot(
peerID: myPeerID,
nickname: "builder",
connected: true,
noisePublicKey: Data(repeating: 0, count: 32)
),
makeArchitectureSnapshot(
peerID: otherPeerID,
nickname: "alice",
connected: false,
noisePublicKey: otherNoiseKey
)
])
locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash)))
await waitUntil {
if case .location(let channel) = locationManager.selectedChannel {
return channel.geohash == geohash && !viewModel.allPeers.isEmpty
}
return false
}
viewModel.participantTracker.setActiveGeohash(geohash)
viewModel.teleportedGeo = Set([remoteGeoID])
viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash)
if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() {
viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash)
}
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore,
locationChannelsModel: locationChannelsModel
)
await waitUntil {
peerListModel.reachableMeshPeerCount == 1 &&
peerListModel.connectedMeshPeerCount == 0 &&
peerListModel.meshRows.contains(where: { $0.peerID == otherPeerID && $0.hasUnread }) &&
peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported })
}
let meshRow = peerListModel.meshRows.first(where: { $0.peerID == otherPeerID })
#expect(peerListModel.reachableMeshPeerCount == 1)
#expect(peerListModel.connectedMeshPeerCount == 0)
#expect(meshRow?.displayName == "alice")
#expect(meshRow?.showsVerifiedBadgeWhenOffline == true)
#expect(meshRow?.hasUnread == true)
#expect(peerListModel.visibleGeohashPeerCount >= 1)
#expect(peerListModel.participantCount(for: geohash) >= 1)
#expect(peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported }))
viewModel.participantTracker.clear()
viewModel.teleportedGeo = []
locationManager.markTeleported(for: geohash, false)
locationManager.select(ChannelID.mesh)
await waitUntil {
if case ChannelID.mesh = locationManager.selectedChannel {
return true
}
return false
}
}
}
@@ -167,6 +167,31 @@ struct ChatViewModelDeliveryStatusTests {
}())
}
@Test @MainActor
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060709")
let message = BitchatMessage(
id: "keep-receipt",
sender: viewModel.nickname,
content: "Keep me",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
viewModel.isStartupPhase = false
viewModel.cleanupOldReadReceipts()
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
}
// MARK: - Public Timeline Status Tests
@Test @MainActor
+481 -3
View File
@@ -67,6 +67,135 @@ struct ChatViewModelInitializationTests {
// Nickname should be set during init
#expect(!transport.myNickname.isEmpty)
}
@Test @MainActor
func initialization_bindsPeerSnapshotsIntoAllPeers() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "00000000000000a1")
let noiseKey = Data(repeating: 0xAB, count: 32)
transport.updatePeerSnapshots([
TransportPeerSnapshot(
peerID: peerID,
nickname: "Alice",
isConnected: true,
noisePublicKey: noiseKey,
lastSeen: Date()
)
])
let updated = await TestHelpers.waitUntil({
viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" }
}, timeout: TestConstants.defaultTimeout)
#expect(updated)
}
}
// MARK: - Identity Tests
struct ChatViewModelIdentityTests {
@Test @MainActor
func updatePrivateChatPeerIfNeeded_migratesSelectionHistoryByFingerprint() async {
let (viewModel, transport) = makeTestableViewModel()
let oldPeerID = PeerID(str: "00000000000000d1")
let newPeerID = PeerID(str: "00000000000000d2")
let sharedFingerprint = "fingerprint-shared"
let sharedNoiseKey = Data(repeating: 0xAB, count: 32)
transport.peerFingerprints[oldPeerID] = sharedFingerprint
transport.peerFingerprints[newPeerID] = sharedFingerprint
transport.peerNicknames[oldPeerID] = "Alice"
transport.peerNicknames[newPeerID] = "Alice"
transport.updatePeerSnapshots([
TransportPeerSnapshot(
peerID: oldPeerID,
nickname: "Alice",
isConnected: true,
noisePublicKey: sharedNoiseKey,
lastSeen: Date()
)
])
let oldPeerBound = await TestHelpers.waitUntil({
viewModel.connectedPeers.contains(oldPeerID)
}, timeout: TestConstants.defaultTimeout)
#expect(oldPeerBound)
let existingMessage = BitchatMessage(
id: "pm-migrate-1",
sender: "Alice",
content: "Still here",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: oldPeerID,
mentions: nil
)
viewModel.privateChats[oldPeerID] = [existingMessage]
viewModel.startPrivateChat(with: oldPeerID)
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
#expect(viewModel.hasTrackedPrivateChatSelection)
#expect(viewModel.selectedPrivateChatFingerprint == viewModel.getFingerprint(for: oldPeerID))
transport.updatePeerSnapshots([
TransportPeerSnapshot(
peerID: newPeerID,
nickname: "Alice",
isConnected: true,
noisePublicKey: sharedNoiseKey,
lastSeen: Date()
)
])
let newPeerBound = await TestHelpers.waitUntil({
viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID)
}, timeout: TestConstants.defaultTimeout)
#expect(newPeerBound)
viewModel.updatePrivateChatPeerIfNeeded()
#expect(viewModel.selectedPrivateChatPeer == newPeerID)
#expect(viewModel.privateChats[oldPeerID] == nil)
#expect(viewModel.privateChats[newPeerID]?.contains(where: { $0.id == "pm-migrate-1" }) == true)
}
@Test @MainActor
func resolveNickname_prefersLocalPetnameOverClaimedNickname() async {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
let peerID = PeerID(str: "00000000000000d3")
let fingerprint = "fingerprint-petname"
transport.peerFingerprints[peerID] = fingerprint
identityManager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: "Buddy",
claimedNickname: "Alice",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
#expect(viewModel.getFingerprint(for: peerID) == fingerprint)
#expect(viewModel.resolveNickname(for: peerID) == "Buddy")
}
}
// MARK: - Message Sending Tests
@@ -135,6 +264,116 @@ struct ChatViewModelCommandTests {
}
}
// MARK: - Composer Tests
struct ChatViewModelComposerTests {
@Test @MainActor
func updateAutocomplete_suggestsKnownPeersAndExcludesSelf() async {
let (viewModel, transport) = makeTestableViewModel()
let alice = PeerID(str: "00000000000000a1")
let selfAlias = PeerID(str: "00000000000000a2")
transport.simulateConnect(alice, nickname: "Alice")
transport.simulateConnect(selfAlias, nickname: viewModel.nickname)
viewModel.updateAutocomplete(for: "hello @Al", cursorPosition: 9)
#expect(viewModel.showAutocomplete)
#expect(viewModel.autocompleteSuggestions == ["@Alice"])
}
@Test @MainActor
func completeNickname_replacesTokenAndClearsAutocompleteState() async {
let (viewModel, _) = makeTestableViewModel()
var text = "hello @Al"
viewModel.autocompleteSuggestions = ["@Alice"]
viewModel.autocompleteRange = NSRange(location: 6, length: 3)
viewModel.showAutocomplete = true
viewModel.selectedAutocompleteIndex = 0
_ = viewModel.completeNickname("@Alice", in: &text)
#expect(text == "hello @Alice")
#expect(viewModel.autocompleteSuggestions.isEmpty)
#expect(viewModel.autocompleteRange == nil)
#expect(!viewModel.showAutocomplete)
#expect(viewModel.selectedAutocompleteIndex == 0)
}
@Test @MainActor
func parseMentions_filtersToKnownPeerAndSelfTokens() async {
let (viewModel, transport) = makeTestableViewModel()
let alice = PeerID(str: "00000000000000b1")
transport.simulateConnect(alice, nickname: "Alice")
let mentions = Set(
viewModel.parseMentions(
from: "hi @Alice @nobody @\(viewModel.nickname)"
)
)
#expect(mentions.contains("Alice"))
#expect(mentions.contains(viewModel.nickname))
#expect(!mentions.contains("nobody"))
}
}
// MARK: - Lifecycle Tests
struct ChatViewModelServiceLifecycleTests {
@Test @MainActor
func handleDidBecomeActive_marksVisiblePrivateMessagesAsRead() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000001")
transport.simulateConnect(peerID, nickname: "Alice")
let message = BitchatMessage(
id: "read-1",
sender: "Alice",
content: "Hello from Alice",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID,
mentions: nil
)
viewModel.privateChats[peerID] = [message]
viewModel.unreadPrivateMessages.insert(peerID)
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleDidBecomeActive()
let sentReadReceipt = await TestHelpers.waitUntil({
transport.sentReadReceipts.contains {
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
}
}, timeout: TestConstants.defaultTimeout)
#expect(sentReadReceipt)
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
}
@Test @MainActor
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleScreenshotCaptured()
#expect(transport.sentPrivateMessages.isEmpty)
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
}
}
// MARK: - Timeline Cap Tests
struct ChatViewModelTimelineCapTests {
@@ -159,7 +398,7 @@ struct ChatViewModelReceivingTests {
@Test @MainActor
func didReceiveMessage_callsDelegate() async {
let (_, transport) = makeTestableViewModel()
let (viewModel, transport) = makeTestableViewModel()
let message = BitchatMessage(
id: "msg-001",
@@ -181,7 +420,7 @@ struct ChatViewModelReceivingTests {
// Message may or may not appear due to rate limiting/pipeline batching
// The important thing is no crash and delegate was called
#expect(transport.delegate != nil)
#expect(transport.delegate === viewModel)
}
@Test @MainActor
@@ -204,6 +443,176 @@ struct ChatViewModelReceivingTests {
}
}
// MARK: - Noise Payload Tests
struct ChatViewModelNoisePayloadTests {
@Test @MainActor
func didReceiveNoisePayload_privateMessageStoresMessageAndSendsDeliveryAck() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000003")
transport.simulateConnect(peerID, nickname: "Alice")
let payload = PrivateMessagePacket(messageID: "pm-noise-1", content: "Secret hello").encode()
#expect(payload != nil)
guard let payload else { return }
viewModel.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: payload,
timestamp: Date()
)
let stored = await TestHelpers.waitUntil({
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
}, timeout: TestConstants.defaultTimeout)
let acked = await TestHelpers.waitUntil({
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
#expect(stored)
#expect(acked)
}
@Test @MainActor
func didReceiveNoisePayload_deliveredUpdatesExistingPrivateMessageStatus() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000004")
transport.simulateConnect(peerID, nickname: "Bob")
let message = BitchatMessage(
id: "pm-delivered-1",
sender: viewModel.nickname,
content: "Waiting on ack",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "Bob",
senderPeerID: viewModel.meshService.myPeerID,
mentions: nil,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.didReceiveNoisePayload(
from: peerID,
type: .delivered,
payload: Data("pm-delivered-1".utf8),
timestamp: Date()
)
let delivered = await TestHelpers.waitUntil({
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
if case .delivered(let name, _) = status {
return name == "Bob"
}
return false
}, timeout: TestConstants.defaultTimeout)
#expect(delivered)
}
}
// MARK: - Formatting Tests
struct ChatViewModelFormattingTests {
@Test @MainActor
func formatMessageAsText_formatsSenderContentAndTimestamp() async {
let (viewModel, _) = makeTestableViewModel()
let message = BitchatMessage(
id: "fmt-1",
sender: "Alice#a1b2",
content: "hello #mesh",
timestamp: Date(timeIntervalSince1970: 1_700_010_000),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b1")
)
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel()
let message = BitchatMessage(
id: "fmt-2",
sender: "Alice#a1b2",
content: "hello",
timestamp: Date(),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b2")
)
let header = viewModel.formatMessageHeader(message, colorScheme: .dark)
#expect(String(header.characters) == "<@Alice#a1b2> ")
}
}
// MARK: - Verification Tests
struct ChatViewModelVerificationTests {
@Test @MainActor
func beginQRVerification_unknownNoiseKeyReturnsFalse() async {
let (viewModel, _) = makeTestableViewModel()
let qr = VerificationService.VerificationQR(
v: 1,
noiseKeyHex: String(repeating: "a", count: 64),
signKeyHex: String(repeating: "b", count: 64),
npub: nil,
nickname: "Alice",
ts: 1_700_000_000,
nonceB64: "nonce",
sigHex: "sig"
)
#expect(!viewModel.beginQRVerification(with: qr))
}
@Test @MainActor
func beginQRVerification_knownPeerTriggersHandshakeWhenSessionNotEstablished() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "00000000000000c1")
let noiseKey = Data(repeating: 0xAB, count: 32)
transport.updatePeerSnapshots([
TransportPeerSnapshot(
peerID: peerID,
nickname: "Alice",
isConnected: true,
noisePublicKey: noiseKey,
lastSeen: Date()
)
])
let bound = await TestHelpers.waitUntil({
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
#expect(bound)
let qr = VerificationService.VerificationQR(
v: 1,
noiseKeyHex: noiseKey.hexEncodedString(),
signKeyHex: String(repeating: "c", count: 64),
npub: nil,
nickname: "Alice",
ts: 1_700_000_000,
nonceB64: "nonce",
sigHex: "sig"
)
#expect(viewModel.beginQRVerification(with: qr))
#expect(transport.triggeredHandshakes == [peerID])
}
}
// MARK: - Rate Limiting Tests
struct ChatViewModelRateLimitingTests {
@@ -238,6 +647,35 @@ struct ChatViewModelRateLimitingTests {
}
}
// MARK: - Public Conversation Tests
struct ChatViewModelPublicConversationTests {
@Test @MainActor
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.addPublicSystemMessage("system refresh test")
viewModel.messages.removeAll()
viewModel.refreshVisibleMessages(from: .mesh)
#expect(viewModel.messages.last?.content == "system refresh test")
}
@Test @MainActor
func clearCurrentPublicTimeline_clearsVisibleAndBackedMessages() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.addPublicSystemMessage("system clear test")
#expect(!viewModel.messages.isEmpty)
viewModel.clearCurrentPublicTimeline()
viewModel.refreshVisibleMessages(from: .mesh)
#expect(viewModel.messages.isEmpty)
}
}
// MARK: - Peer Connection Tests
struct ChatViewModelPeerTests {
@@ -275,6 +713,46 @@ struct ChatViewModelPeerTests {
#expect(transport.isPeerConnected(peerID))
}
@Test @MainActor
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
let (viewModel, _) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
viewModel.unreadPrivateMessages = [stalePeer]
viewModel.didUpdatePeerList([])
let cleaned = await TestHelpers.waitUntil({
!viewModel.unreadPrivateMessages.contains(stalePeer)
}, timeout: TestConstants.defaultTimeout)
#expect(cleaned)
}
@Test @MainActor
func didUpdatePeerList_preservesStableUnreadPeerWhenMessagesExist() async {
let (viewModel, _) = makeTestableViewModel()
let stablePeer = PeerID(str: String(repeating: "a", count: 64))
let message = BitchatMessage(
id: "stable-noise-1",
sender: "Alice",
content: "Offline hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: stablePeer,
mentions: nil
)
viewModel.privateChats[stablePeer] = [message]
viewModel.unreadPrivateMessages = [stablePeer]
viewModel.didUpdatePeerList([])
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.unreadPrivateMessages.contains(stablePeer))
}
}
// MARK: - Deduplication Integration Tests
@@ -328,7 +806,7 @@ struct ChatViewModelPrivateChatTests {
// The message routing depends on connection state and other factors
// At minimum, it should not crash
#expect(true) // If we get here without crash, the test passes
#expect(Bool(true)) // If we get here without crash, the test passes
}
}
+33 -20
View File
@@ -53,7 +53,7 @@ struct CommandProcessorTests {
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/msg @alice hello there")
}
@@ -72,14 +72,15 @@ struct CommandProcessorTests {
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/who")
}
@@ -105,7 +106,7 @@ struct CommandProcessorTests {
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = await withSelectedChannel(channel) {
let result = await withSelectedChannel(channel, context: context) {
processor.process("/who")
}
@@ -130,7 +131,7 @@ struct CommandProcessorTests {
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/clear")
}
@@ -150,7 +151,7 @@ struct CommandProcessorTests {
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/clear")
}
@@ -174,7 +175,7 @@ struct CommandProcessorTests {
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/hug @bob")
}
@@ -198,7 +199,7 @@ struct CommandProcessorTests {
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/slap @bob")
}
@@ -227,7 +228,7 @@ struct CommandProcessorTests {
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/block")
}
@@ -249,7 +250,7 @@ struct CommandProcessorTests {
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
let blockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/block @bob")
}
switch blockResult {
@@ -260,7 +261,7 @@ struct CommandProcessorTests {
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh) {
let unblockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unblock bob")
}
switch unblockResult {
@@ -279,7 +280,7 @@ struct CommandProcessorTests {
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
let blockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/block carol")
}
switch blockResult {
@@ -290,7 +291,7 @@ struct CommandProcessorTests {
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh) {
let unblockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unblock @carol")
}
switch unblockResult {
@@ -305,14 +306,15 @@ struct CommandProcessorTests {
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(
contextProvider: MockCommandContextProvider(),
contextProvider: context,
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel) {
let result = await withSelectedChannel(channel, context: context) {
processor.process("/fav alice")
}
@@ -325,21 +327,31 @@ struct CommandProcessorTests {
}
@MainActor
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
private func withSelectedChannel<T>(
_ channel: ChannelID,
context: MockCommandContextProvider? = nil,
perform work: @escaping () throws -> T
) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
await setSelectedChannel(channel)
let originalContextChannel = context?.activeChannel
await setSelectedChannel(channel, context: context)
do {
let result = try work()
await setSelectedChannel(originalChannel)
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
return result
} catch {
await setSelectedChannel(originalChannel)
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(_ channel: ChannelID) async {
private func setSelectedChannel(
_ channel: ChannelID,
context: MockCommandContextProvider? = nil,
explicitChannel: ChannelID? = nil
) async {
context?.activeChannel = explicitChannel ?? channel
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
@@ -363,6 +375,7 @@ struct CommandProcessorTests {
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
@@ -12,26 +12,12 @@ import CoreBluetooth
import BitFoundation
@testable import bitchat
@Suite("Fragmentation Tests", .serialized)
struct FragmentationTests {
private let mockKeychain: MockKeychain
private let mockIdentityManager: MockIdentityManager
private let idBridge: NostrIdentityBridge
init() {
mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain)
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
}
@Test("Reassembly from fragments delivers a public message")
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let ble = makeBLEService()
let capture = CaptureDelegate()
ble.delegate = capture
@@ -54,7 +40,7 @@ struct FragmentationTests {
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000)
@@ -62,12 +48,7 @@ struct FragmentationTests {
@Test("Duplicate fragment does not break reassembly")
func duplicateFragmentDoesNotBreakReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let ble = makeBLEService()
let capture = CaptureDelegate()
ble.delegate = capture
@@ -89,7 +70,7 @@ struct FragmentationTests {
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048)
@@ -97,12 +78,7 @@ struct FragmentationTests {
@Test("Max-sized file transfer survives reassembly")
func maxSizedFileTransferSurvivesReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let ble = makeBLEService()
let capture = CaptureDelegate()
ble.delegate = capture
@@ -131,14 +107,13 @@ struct FragmentationTests {
#expect(!fragments.isEmpty)
for (i, fragment) in fragments.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteID)
}
}
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5))
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
@@ -154,12 +129,7 @@ struct FragmentationTests {
@Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let ble = makeBLEService()
let capture = CaptureDelegate()
ble.delegate = capture
@@ -184,11 +154,10 @@ struct FragmentationTests {
}
for (i, fragment) in corrupted.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Allow async processing
@@ -200,6 +169,19 @@ struct FragmentationTests {
}
extension FragmentationTests {
private func makeBLEService() -> BLEService {
let mockKeychain = MockKeychain()
let mockIdentityManager = MockIdentityManager(mockKeychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
}
/// Thread-safe delegate that supports awaiting message delivery
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
@@ -4,6 +4,8 @@ import XCTest
@MainActor
final class NostrRelayManagerTests: XCTestCase {
private let expectedDefaultRelayCount = 4
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
let context = makeContext(permission: .authorized, activationAllowed: false)
@@ -13,7 +15,7 @@ final class NostrRelayManagerTests: XCTestCase {
context.manager.connect()
let connected = await waitUntil {
context.sessionFactory.requestedURLs.count == 5 &&
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
@@ -27,7 +29,7 @@ final class NostrRelayManagerTests: XCTestCase {
context.permissionSubject.send(.authorized)
let defaultRelaysConnected = await waitUntil {
context.manager.getRelayStatuses().count == 5 &&
context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(defaultRelaysConnected)
@@ -38,7 +40,7 @@ final class NostrRelayManagerTests: XCTestCase {
context.manager.getRelayStatuses().isEmpty
}
XCTAssertTrue(defaultRelaysRemoved)
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount)
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
}
@@ -52,7 +54,26 @@ final class NostrRelayManagerTests: XCTestCase {
context.torWaiter.resolve(true)
let connectedAfterTorReady = await waitUntil {
context.sessionFactory.requestedURLs.count == 5 &&
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterTorReady)
}
func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async {
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.connect()
context.manager.connect()
context.manager.connect()
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connectedAfterTorReady = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterTorReady)
@@ -87,6 +108,24 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(sentAfterTorReady)
}
func test_sendEvent_queuesUntilRelayIsMarkedConnected() async throws {
let relayURL = "wss://connect-before-send.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "wait for connected")
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 0)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
let sentAfterConnected = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.debugPendingMessageQueueCount == 0 &&
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
}
XCTAssertTrue(sentAfterConnected)
}
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
let relayURL = "wss://queue-flush.example"
let context = makeContext(
@@ -179,6 +218,26 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
}
func test_ensureConnections_coalescesTargetsWhileWaitingForTor() async {
let relayOne = "wss://tor-one.example"
let relayTwo = "wss://tor-two.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
context.manager.ensureConnections(to: [relayOne])
context.manager.ensureConnections(to: [relayTwo, relayOne])
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(true)
let connected = await waitUntil {
Set(context.sessionFactory.requestedURLs) == Set([relayOne, relayTwo]) &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
}
func test_subscribe_coalescesRapidDuplicateRequests() async {
let relayURL = "wss://subscribe.example"
let context = makeContext(permission: .denied)
@@ -197,6 +256,63 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
}
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
let relayURL = "wss://tor-subscribe-coalesce.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-sub",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
context.manager.subscribe(
filter: makeFilter(),
id: "tor-sub",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
context.torWaiter.resolve(true)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-sub")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
}
func test_subscribe_sameActiveRequestDoesNotRequeue() async {
let relayURL = "wss://active-subscribe.example"
let context = makeContext(permission: .denied)
let filter = makeFilter()
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
let firstSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(firstSent)
context.clock.now = context.clock.now.addingTimeInterval(2.0)
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
}
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
let relayURL = "wss://tor-subscribe.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -239,7 +355,7 @@ final class NostrRelayManagerTests: XCTestCase {
context.permissionSubject.send(.authorized)
let connected = await waitUntil {
context.sessionFactory.allConnections.count == 5 &&
context.sessionFactory.allConnections.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connected)
@@ -810,6 +926,7 @@ private final class MutableBool {
private final class MockTorWaiter {
private var completions: [(Bool) -> Void] = []
private(set) var awaitCallCount = 0
var isReady: Bool
init(isReady: Bool) {
@@ -817,6 +934,7 @@ private final class MockTorWaiter {
}
func await(completion: @escaping (Bool) -> Void) {
awaitCallCount += 1
completions.append(completion)
}
+208 -24
View File
@@ -29,6 +29,151 @@ private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockT
return (viewModel, transport, identityManager)
}
@MainActor
private struct SmokeFeatureModels {
let publicChatModel: PublicChatModel
let appChromeModel: AppChromeModel
let locationChannelsModel: LocationChannelsModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel
}
@MainActor
private func makeSmokeLocationManager() -> LocationChannelManager {
let suiteName = "ViewSmokeTests.\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName) ?? .standard
storage.removePersistentDomain(forName: suiteName)
return LocationChannelManager(storage: storage)
}
@MainActor
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
let locationManager = makeSmokeLocationManager()
let conversationStore = viewModel.conversationStore
let publicChatModel = PublicChatModel(conversationStore: conversationStore)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let appChromeModel = AppChromeModel(
chatViewModel: viewModel,
privateInboxModel: privateInboxModel
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: conversationStore
)
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
locationChannelsModel: locationChannelsModel
)
return SmokeFeatureModels(
publicChatModel: publicChatModel,
appChromeModel: appChromeModel,
locationChannelsModel: locationChannelsModel,
privateInboxModel: privateInboxModel,
privateConversationModel: privateConversationModel,
verificationModel: verificationModel,
conversationUIModel: conversationUIModel,
peerListModel: peerListModel
)
}
@MainActor
private func installSmokeEnvironment<V: View>(
_ view: V,
featureModels: SmokeFeatureModels
) -> some View {
view
.environmentObject(featureModels.publicChatModel)
.environmentObject(featureModels.appChromeModel)
.environmentObject(featureModels.locationChannelsModel)
.environmentObject(featureModels.privateInboxModel)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.verificationModel)
.environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel)
}
@MainActor
private struct ContentPeopleSheetHarness: View {
@State private var showSidebar = true
@State private var messageText = ""
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@State private var imagePreviewURL: URL?
@State private var windowCountPublic = 300
@State private var windowCountPrivate: [PeerID: Int] = [:]
@State private var isAtBottomPrivate = true
@State private var autocompleteDebounceTimer: Timer?
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@FocusState private var isTextFieldFocused: Bool
#if os(iOS)
@State private var showImagePicker = false
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary
#else
@State private var showMacImagePicker = false
#endif
var body: some View {
#if os(iOS)
ContentPeopleSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44,
onSendMessage: {},
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44,
onSendMessage: {},
showMacImagePicker: $showMacImagePicker
)
#endif
}
}
@MainActor
@discardableResult
private func mount<V: View>(_ view: V) -> AnyObject {
@@ -112,11 +257,13 @@ private func makeTemporaryImageURL() throws -> URL {
return url
}
@Suite("View Smoke Tests", .serialized)
@MainActor
struct ViewSmokeTests {
@Test
func fingerprintView_renders_verifiedAndPendingStates() async {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let verifiedPeer = PeerID(str: "0102030405060708")
let pendingPeer = PeerID(str: "1112131415161718")
let verifiedFingerprint = String(repeating: "ab", count: 32)
@@ -131,11 +278,11 @@ struct ViewSmokeTests {
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer)
let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer)
let verifiedView = FingerprintView(peerID: verifiedPeer)
.environmentObject(featureModels.verificationModel)
let pendingView = FingerprintView(peerID: pendingPeer)
.environmentObject(featureModels.verificationModel)
_ = verifiedView.body
_ = pendingView.body
_ = mount(verifiedView)
_ = mount(pendingView)
@@ -145,13 +292,14 @@ struct ViewSmokeTests {
@Test
func verificationViews_renderCoreBranches() throws {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let peerID = PeerID(str: "2122232425262728")
let fingerprint = String(repeating: "cd", count: 32)
var isPresented = true
transport.peerFingerprints[peerID] = fingerprint
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
viewModel.selectedPrivateChatPeer = peerID
featureModels.privateConversationModel.startConversation(with: peerID)
viewModel.verifiedFingerprints.insert(fingerprint)
let image = try makeCGImage()
@@ -173,35 +321,36 @@ struct ViewSmokeTests {
set: { isPresented = $0 }
)
)
.environmentObject(viewModel)
.environmentObject(featureModels.verificationModel)
)
}
@Test
func meshPeerList_renders_emptyAndPopulatedStates() async {
let (viewModel, transport, identityManager) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let connectedPeer = PeerID(str: "3132333435363738")
let blockedPeer = PeerID(str: "4142434445464748")
let blockedFingerprint = String(repeating: "ef", count: 32)
_ = mount(
MeshPeerList(
viewModel: viewModel,
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
_ = MeshPeerList(
viewModel: viewModel,
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
).body
)
.environmentObject(featureModels.peerListModel)
transport.peerFingerprints[blockedPeer] = blockedFingerprint
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
@@ -214,13 +363,13 @@ struct ViewSmokeTests {
_ = mount(
MeshPeerList(
viewModel: viewModel,
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
@@ -229,10 +378,11 @@ struct ViewSmokeTests {
@Test
func commandSuggestionsAndLocationViews_render() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
var messageText = "/f"
LocationChannelManager.shared.select(.location(channel))
featureModels.locationChannelsModel.select(.location(channel))
_ = mount(
CommandSuggestionsView(
@@ -244,22 +394,25 @@ struct ViewSmokeTests {
backgroundColor: .black,
secondaryTextColor: .gray
)
.environmentObject(viewModel)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationChannelsSheet(isPresented: .constant(true))
.environmentObject(viewModel)
.environmentObject(featureModels.locationChannelsModel)
.environmentObject(featureModels.peerListModel)
)
#expect(messageText == "/f")
LocationChannelManager.shared.select(.mesh)
LocationChannelManager.shared.endLiveRefresh()
featureModels.locationChannelsModel.select(.mesh)
featureModels.locationChannelsModel.endLiveRefresh()
}
@Test
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let noRelayManager = LocationNotesManager(
geohash: "u4pruydq",
@@ -302,12 +455,20 @@ struct ViewSmokeTests {
eose?()
_ = mount(
LocationNotesView(geohash: "u4pruydq", manager: noRelayManager)
.environmentObject(viewModel)
LocationNotesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname,
manager: noRelayManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationNotesView(geohash: "u4pruydq", manager: loadedManager)
.environmentObject(viewModel)
LocationNotesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname,
manager: loadedManager
)
.environmentObject(featureModels.locationChannelsModel)
)
#expect(loadedManager.notes.count == 1)
@@ -350,11 +511,34 @@ struct ViewSmokeTests {
#expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning")
}
@Test
func contentShellViews_renderPublicAndPrivateBranches() async {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let peerID = PeerID(str: "5152535455565758")
transport.updatePeerSnapshots([
makeSnapshot(peerID: peerID, nickname: "Alice", noiseByte: 0x66)
])
try? await Task.sleep(nanoseconds: 50_000_000)
_ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels))
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
featureModels.privateConversationModel.startConversation(with: peerID)
try? await Task.sleep(nanoseconds: 50_000_000)
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
#expect(featureModels.privateConversationModel.selectedPeerID == peerID)
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
}
@Test
func geohashAndTextMessageViews_renderCoreBranches() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let geohashPeopleList = GeohashPeopleList(
viewModel: viewModel,
textColor: .green,
secondaryTextColor: .gray,
onTapPerson: {}
@@ -377,10 +561,10 @@ struct ViewSmokeTests {
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
)
_ = geohashPeopleList.body
_ = mount(geohashPeopleList)
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel))
_ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel))
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel))
_ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel))
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
+40
View File
@@ -0,0 +1,40 @@
# Architecture V2
This branch starts a larger simplification effort focused on performance, reliability, and maintainability without rewriting the transport and protocol stack in one pass.
## What Landed
1. `AppRuntime` is now the composition root for startup, lifecycle, notification routing, and shared-content intake.
2. `BitchatApp` is reduced to app wiring and no longer owns operational startup logic.
3. `AppEventStream` introduces a typed async event surface for app-level events.
4. `PeerHandle` and `ConversationID` establish canonical identity/conversation keys above the existing transport-specific IDs.
5. `IdentityResolver` mirrors the current peer graph into canonical handles.
6. `ConversationStore` now owns app-layer conversation context for the active public channel and selected private conversation, while also mirroring the current public/private message timelines under canonical IDs.
7. `AppRuntime` now owns Tor, screenshot, and Nostr reconnect observer wiring instead of leaving that app-lifecycle graph inside `ChatViewModel`.
8. `PublicChatModel`, `PrivateInboxModel`, `PrivateConversationModel`, `ConversationUIModel`, `LocationChannelsModel`, and `PeerListModel` provide focused view-facing models for public chat, private inbox state, selected-DM state, composer/message interactions, location-channel state, and peer counts. `PrivateInboxModel` now reads its message, unread, and selection state directly from `ConversationStore` instead of merging through `PrivateChatManager`.
9. `AppChromeModel` now owns app-chrome presentation state for nickname editing, fingerprint routing, app-info/location sheet visibility, unread-private-chat affordances, and screenshot/privacy UI.
10. `ContentView`, `MessageListView`, `LocationChannelsSheet`, `LocationNotesView`, `CommandSuggestionsView`, `FingerprintView`, and `VerificationSheetView` no longer talk directly to `ChatViewModel` or location singletons for their primary app-layer state; they consume feature-scoped models injected from `AppRuntime`.
11. `PeerListModel` now consumes the runtime-owned `LocationChannelsModel` instead of reading the location manager directly, and QR scan parsing is funneled through `VerificationModel` rather than the SwiftUI views.
12. `ChatViewModel` is thinner on the app side: startup wiring now lives in `ChatViewModelBootstrapper`, send/lifecycle/transport seams live in focused coordinators, and peer-list notification/timer state now lives in `ChatPeerListCoordinator` instead of the main view model.
## Why This Shape
- `ChatViewModel` remains the active domain/UI bridge so the app can keep working while we migrate feature-by-feature.
- The new runtime/store layer gives us a safe place to move lifecycle, routing, and cross-cutting state before we split the UI into smaller feature models.
- `ConversationStore` now owns the selected conversation context and private-inbox read state, which lets feature models read one runtime-managed source of truth for focus/navigation and DM timelines even while legacy mutation paths still flow through `ChatViewModel`.
- The new chrome model lets us remove presentation-only state from `ChatViewModel` without forcing a big rewrite of the domain/message paths.
- The private-conversation model pulls selected-DM header identity, availability, encryption, and favorite state out of `ContentView`, which keeps the view from re-implementing peer lookup rules.
- The conversation UI model centralizes composer state, autocomplete, message formatting, and row-level actions so the main views no longer proxy most conversation interactions directly to `ChatViewModel`.
- The location channels model gives the remaining location-heavy views a focused adapter over the location/bookmark/network singletons, which reduces direct global reads in the view layer and makes smoke/architecture tests more reliable.
- Reusing that same location model inside the peer-list feature keeps geohash/mesh presence presentation on one runtime-owned source of truth instead of mixing view models with singleton reads.
- `BitchatApp` now injects only feature-scoped models into the view tree, which removes the old pattern of keeping `ChatViewModel` as a global environment object for the entire app shell.
- Routing QR scan parsing through the verification model keeps the verification UI as a passive renderer and gives us a clearer seam for future camera/manual-scan behavior changes.
- The new `ChatViewModel` coordinators are intentionally transitional: they shrink the main file and isolate composition, lifecycle, transport, and peer-list responsibilities without forcing a risky rewrite of the transport/BLE core in the same pass.
## Next Steps
1. Migrate view composition from one global `ChatViewModel` to focused feature models backed by `ConversationStore`.
2. Route more inbound transport events through typed app/domain events instead of delegate plus notification fan-out.
3. Move private/public conversation mutation behind the new store instead of still mirroring legacy message writes from `ChatViewModel`.
4. Replace remaining singleton-heavy seams with injected runtime services where practical.
5. Revisit actor isolation for identity and conversation state once the remaining message/peer models are safe to move off the main actor.