mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
[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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+42
-200
@@ -6,128 +6,55 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
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,79 +83,42 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let options = await self.runtime?.presentationOptions(
|
||||
forNotificationIdentifier: identifier,
|
||||
userInfo: userInfo
|
||||
) ?? [.banner, .sound]
|
||||
completionHandler(options)
|
||||
}
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -361,6 +350,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(
|
||||
@@ -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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||
return
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
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)
|
||||
@@ -525,6 +514,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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
+316
-2738
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
nostrCoordinator.sendGeohash(context: context)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
@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) {
|
||||
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
|
||||
}
|
||||
|
||||
let peerID = PeerID(hexData: noisePublicKey)
|
||||
|
||||
// Route via message router
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
-1047
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,38 +44,34 @@ 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) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
if person.isBlocked {
|
||||
Image(systemName: "nosign")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help(Strings.blockedTooltip)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
// 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
|
||||
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?()
|
||||
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)
|
||||
lastValid = input
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
} else {
|
||||
result = Strings.notFound
|
||||
}
|
||||
} else {
|
||||
switch verificationModel.verifyScannedPayload(input) {
|
||||
case .requested(let nickname):
|
||||
result = Strings.requested(nickname)
|
||||
lastValid = input
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
case .notFound:
|
||||
result = Strings.notFound
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user