mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-189
@@ -6,9 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Tor
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -16,118 +14,47 @@ struct BitchatApp: App {
|
|||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var runtime: AppRuntime
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@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)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
|
||||||
wrappedValue: ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
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 {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
appDelegate.runtime = runtime
|
||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
runtime.start()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleURL(url)
|
runtime.handleOpenURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
switch newPhase {
|
runtime.handleScenePhaseChange(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Check for shared content when app becomes active
|
runtime.handleDidBecomeActiveNotification()
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
// App became active
|
runtime.handleMacDidBecomeActiveNotification()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -136,66 +63,18 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -204,44 +83,29 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor in
|
||||||
if identifier.hasPrefix("private-") {
|
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||||
// Get peer ID from userInfo
|
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,34 +113,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task {
|
||||||
if identifier.hasPrefix("private-") {
|
let options = await self.runtime?.presentationOptions(
|
||||||
// Get peer ID from userInfo
|
forNotificationIdentifier: identifier,
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
userInfo: userInfo
|
||||||
// Don't show notification if the private chat is already open
|
) ?? [.banner, .sound]
|
||||||
// Access main-actor-isolated property via Task
|
completionHandler(options)
|
||||||
Task { @MainActor in
|
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
|
||||||
let deep = userInfo["deeplink"] as? String,
|
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,13 +121,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var nextReconnectTime: Date?
|
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 = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub"
|
||||||
"wss://nostr21.com"
|
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
private static let defaultRelaySet = Set(defaultRelays)
|
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 subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
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 subscribeCoalesce: [String: Date] = [:]
|
||||||
|
private var pendingTorConnectionURLs = Set<String>()
|
||||||
|
private var awaitingTorForConnections = false
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
var pendingRelays: Set<String>
|
var pendingRelays: Set<String>
|
||||||
@@ -154,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var timer: Timer?
|
var timer: Timer?
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// Pending sends held only for relays that are not yet connected.
|
||||||
@@ -233,25 +242,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor {
|
connectToRelays(relays.map(\.url), shouldLog: true)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
@@ -264,6 +255,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Clear known subscriptions and any queued subs since connections are gone
|
// Clear known subscriptions and any queued subs since connections are gone
|
||||||
subscriptions.removeAll()
|
subscriptions.removeAll()
|
||||||
pendingSubscriptions.removeAll()
|
pendingSubscriptions.removeAll()
|
||||||
|
subscriptionRequestState.removeAll()
|
||||||
|
pendingEOSECallbacks.removeAll()
|
||||||
|
for (_, tracker) in eoseTrackers {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
}
|
||||||
|
eoseTrackers.removeAll()
|
||||||
|
pendingTorConnectionURLs.removeAll()
|
||||||
|
awaitingTorForConnections = false
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,22 +272,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
let targets = allowedRelayList(from: relayUrls)
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
guard !targets.isEmpty else { return }
|
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 })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in targets where !existing.contains(url) {
|
for url in targets where !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
for url in targets where connections[url] == nil {
|
connectToRelays(targets)
|
||||||
connectToRelay(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// 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
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
var stillPending = Set<String>()
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connectedConnection(for: relayUrl) {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
} else {
|
} else {
|
||||||
stillPending.insert(relayUrl)
|
stillPending.insert(relayUrl)
|
||||||
@@ -333,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Flush only for a specific relay
|
// Flush only for a specific relay
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
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)
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
item.pendingRelays.remove(target)
|
item.pendingRelays.remove(target)
|
||||||
if item.pendingRelays.isEmpty {
|
if item.pendingRelays.isEmpty {
|
||||||
@@ -348,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
var item = messageQueue[i]
|
||||||
for url in item.pendingRelays {
|
for url in item.pendingRelays {
|
||||||
if let conn = connections[url] {
|
if let conn = connectedConnection(for: url) {
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
item.pendingRelays.remove(url)
|
item.pendingRelays.remove(url)
|
||||||
}
|
}
|
||||||
@@ -362,6 +351,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||||
|
guard let connection = connections[relayUrl],
|
||||||
|
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
@@ -372,24 +369,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
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()
|
let now = dependencies.now()
|
||||||
if messageHandlers[id] != nil {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if ready {
|
|
||||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
@@ -407,10 +392,17 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||||
let urls = allowedRelayList(from: candidateUrls)
|
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
|
// 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) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
|
existingSet.insert(url)
|
||||||
}
|
}
|
||||||
for url in urls {
|
for url in urls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
@@ -421,20 +413,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
if let onEOSE = onEOSE {
|
if let onEOSE = onEOSE {
|
||||||
if urls.isEmpty {
|
if urls.isEmpty {
|
||||||
onEOSE()
|
onEOSE()
|
||||||
|
} else if shouldWaitForTorBeforeConnecting {
|
||||||
|
pendingEOSECallbacks[id] = onEOSE
|
||||||
} else {
|
} else {
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
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)
|
messageHandlers.removeValue(forKey: id)
|
||||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||||
subscribeCoalesce.removeValue(forKey: id)
|
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 req = NostrRequest.close(id: id)
|
||||||
let message = try? encoder.encode(req)
|
let message = try? encoder.encode(req)
|
||||||
@@ -526,6 +515,100 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// 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) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
@@ -550,12 +633,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldWaitForTorBeforeConnecting {
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
queueConnectionsUntilTorReady([urlString])
|
||||||
guard let self = self else { return }
|
|
||||||
if ready { self.connectToRelay(urlString) }
|
|
||||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard let connection = connections[relayUrl] else { return }
|
guard let connection = connections[relayUrl] else { return }
|
||||||
for (id, messageString) in map {
|
for (id, messageString) in map {
|
||||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||||
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
connection.send(.string(messageString)) { error in
|
connection.send(.string(messageString)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||||
@@ -671,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
if isGiftWrap {
|
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 {
|
} 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:
|
case .notice:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct CommandGeoParticipant {
|
|||||||
protocol CommandContextProvider: AnyObject {
|
protocol CommandContextProvider: AnyObject {
|
||||||
// MARK: - State Properties
|
// MARK: - State Properties
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var blockedUsers: Set<String> { get }
|
var blockedUsers: Set<String> { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
@@ -75,7 +76,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||||
let inGeoPublic: Bool = {
|
let inGeoPublic: Bool = {
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .mesh: return false
|
case .mesh: return false
|
||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleWho() -> CommandResult {
|
private func handleWho() -> CommandResult {
|
||||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
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)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+308
-2730
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
|
// Geohash and Nostr logic for ChatViewModel
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
import SwiftUI
|
import Foundation
|
||||||
import Tor
|
|
||||||
|
|
||||||
extension ChatViewModel {
|
extension ChatViewModel {
|
||||||
|
|
||||||
// MARK: - Geohash Subscription
|
|
||||||
|
|
||||||
// Resubscribe to the active geohash channel without clearing timeline
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func resubscribeCurrentGeohash() {
|
func resubscribeCurrentGeohash() {
|
||||||
guard case .location(let ch) = activeChannel else { return }
|
nostrCoordinator.resubscribeCurrentGeohash()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
guard event.isValidSignature() else { return }
|
nostrCoordinator.subscribeNostrEvent(event)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Channel Handling
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
func switchLocationChannel(to channel: ChannelID) {
|
||||||
// Reset pending public batches to avoid cross-channel bleed
|
nostrCoordinator.switchLocationChannel(to: channel)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
guard event.isValidSignature() else { return }
|
nostrCoordinator.handleNostrEvent(event)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||||
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
nostrCoordinator.subscribeToGeoChat(ch)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendGeohash(context: GeoOutgoingContext) {
|
func sendGeohash(context: GeoOutgoingContext) {
|
||||||
let ch = context.channel
|
nostrCoordinator.sendGeohash(context: context)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sampling
|
|
||||||
|
|
||||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
func beginGeohashSampling(for geohashes: [String]) {
|
||||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
nostrCoordinator.beginGeohashSampling(for: geohashes)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribe(_ gh: String) {
|
func subscribe(_ gh: String) {
|
||||||
let subID = "geo-sample-\(gh)"
|
nostrCoordinator.subscribe(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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||||
guard event.isValidSignature() else { return }
|
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||||
let now = Date()
|
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop sampling all extra geohashes.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func endGeohashSampling() {
|
func endGeohashSampling() {
|
||||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
nostrCoordinator.endGeohashSampling()
|
||||||
geoSamplingSubs.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Nostr DM Handling
|
@MainActor
|
||||||
|
|
||||||
func setupNostrMessageHandling() {
|
func setupNostrMessageHandling() {
|
||||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
nostrCoordinator.setupNostrMessageHandling()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
// Deduplicate messages by ID
|
nostrCoordinator.handleNostrMessage(giftWrap)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
await nostrCoordinator.processNostrMessage(giftWrap)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
// Check favorites for this Nostr key
|
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
||||||
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 {
|
@MainActor
|
||||||
// Search through favorites for matching Nostr pubkey
|
func sendDeliveryAckViaNostrEmbedded(
|
||||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
_ message: BitchatMessage,
|
||||||
// Compare against stored key (could be hex or npub)
|
wasReadBefore: Bool,
|
||||||
if storedNostrKey == npubToMatch {
|
senderPubkey: String,
|
||||||
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
|
key: Data?
|
||||||
return relationship.peerNoisePublicKey
|
) {
|
||||||
}
|
nostrCoordinator.sendDeliveryAckViaNostrEmbedded(
|
||||||
|
message,
|
||||||
// Also try comparing raw hex if stored key is hex
|
wasReadBefore: wasReadBefore,
|
||||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
senderPubkey: senderPubkey,
|
||||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
key: key
|
||||||
return relationship.peerNoisePublicKey
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
|
||||||
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
|
|
||||||
if let _ = key {
|
|
||||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
|
||||||
// check if we have an identity
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same for READ receipt if viewing
|
|
||||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
|
||||||
if let _ = key {
|
|
||||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
}
|
|
||||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
|
||||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
|
||||||
nt.senderPeerID = meshService.myPeerID
|
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
|
||||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||||
// Try to find Noise key associated with this Nostr pubkey
|
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||||
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 sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
// Find peer Nostr key
|
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||||
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)
|
@MainActor
|
||||||
|
|
||||||
// Route via message router
|
|
||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
|
||||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
|
||||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
|
||||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
|
||||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
|
||||||
guard encoded.count <= maxEncoded else { return nil }
|
|
||||||
guard let packetData = Self.base64URLDecode(encoded),
|
|
||||||
packetData.count <= maxBytes
|
|
||||||
else { return nil }
|
|
||||||
return BitchatPacket.from(packetData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
|
||||||
|
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
nostrCoordinator.nostrPubkeyForDisplayName(name)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func startGeohashDM(withPubkeyHex hex: String) {
|
func startGeohashDM(withPubkeyHex hex: String) {
|
||||||
let convKey = PeerID(nostr_: hex)
|
nostrCoordinator.startGeohashDM(withPubkeyHex: hex)
|
||||||
nostrKeyMapping[convKey] = hex
|
|
||||||
startPrivateChat(with: convKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||||
return nostrKeyMapping[senderID]
|
nostrCoordinator.fullNostrHex(forSenderPeerID: senderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||||
guard let full = nostrKeyMapping[convKey] else {
|
nostrCoordinator.geohashDisplayName(for: convKey)
|
||||||
return convKey.bare
|
|
||||||
}
|
|
||||||
return displayNameForNostrPubkey(full)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
struct CommandSuggestionsView: View {
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
|
|
||||||
@Binding var messageText: String
|
@Binding var messageText: String
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ struct CommandSuggestionsView: View {
|
|||||||
|
|
||||||
private var filteredCommands: [CommandInfo] {
|
private var filteredCommands: [CommandInfo] {
|
||||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||||
let isGeoPublic = locationManager.selectedChannel.isLocation
|
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||||
command.alias.starts(with: messageText.lowercased())
|
command.alias.starts(with: messageText.lowercased())
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,11 @@ struct CommandSuggestionsView: View {
|
|||||||
idBridge: NostrIdentityBridge(),
|
idBridge: NostrIdentityBridge(),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
|
let privateConversationModel = PrivateConversationModel(
|
||||||
|
chatViewModel: viewModel,
|
||||||
|
conversationStore: viewModel.conversationStore
|
||||||
|
)
|
||||||
|
let locationChannelsModel = LocationChannelsModel()
|
||||||
|
|
||||||
CommandSuggestionsView(
|
CommandSuggestionsView(
|
||||||
messageText: $messageText,
|
messageText: $messageText,
|
||||||
@@ -86,5 +91,6 @@ struct CommandSuggestionsView: View {
|
|||||||
backgroundColor: .primary,
|
backgroundColor: .primary,
|
||||||
secondaryTextColor: .secondary
|
secondaryTextColor: .secondary
|
||||||
)
|
)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(privateConversationModel)
|
||||||
|
.environmentObject(locationChannelsModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import BitFoundation
|
|||||||
|
|
||||||
struct TextMessageView: View {
|
struct TextMessageView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
|
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
@@ -24,13 +24,13 @@ struct TextMessageView: View {
|
|||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
if message.isPrivate && message.sender == viewModel.nickname,
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
let status = message.deliveryStatus {
|
let status = message.deliveryStatus {
|
||||||
DeliveryStatusView(status: status)
|
DeliveryStatusView(status: status)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
@@ -69,6 +69,20 @@ struct TextMessageView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let keychain = PreviewKeychainManager()
|
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 {
|
Group {
|
||||||
List {
|
List {
|
||||||
@@ -87,11 +101,5 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
.environment(\.colorScheme, .dark)
|
.environment(\.colorScheme, .dark)
|
||||||
}
|
}
|
||||||
.environmentObject(
|
.environmentObject(conversationUIModel)
|
||||||
ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: NostrIdentityBridge(),
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-1036
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -47,6 +47,8 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
@@ -65,38 +67,19 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
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 {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = fingerprintState.encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
.foregroundColor(fingerprintState.encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(peerNickname)
|
Text(fingerprintState.peerNickname)
|
||||||
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(encryptionStatus.description)
|
Text(fingerprintState.encryptionStatus.description)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
}
|
}
|
||||||
@@ -113,7 +96,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = fingerprintState.theirFingerprint {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -148,8 +131,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
let myFingerprint = viewModel.getMyFingerprint()
|
Text(formatFingerprint(fingerprintState.myFingerprint))
|
||||||
Text(formatFingerprint(myFingerprint))
|
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
@@ -162,30 +144,28 @@ struct FingerprintView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(Strings.copy) {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = myFingerprint
|
UIPasteboard.general.string = fingerprintState.myFingerprint
|
||||||
#else
|
#else
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(myFingerprint, forType: .string)
|
NSPasteboard.general.setString(fingerprintState.myFingerprint, forType: .string)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if fingerprintState.canToggleVerification {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if isVerified {
|
if fingerprintState.isVerified {
|
||||||
Text(Strings.verifiedMessage)
|
Text(Strings.verifiedMessage)
|
||||||
} else {
|
} else {
|
||||||
Text(Strings.verifyHint(peerNickname))
|
Text(Strings.verifyHint(fingerprintState.peerNickname))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
@@ -195,9 +175,9 @@ struct FingerprintView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
if !isVerified {
|
if !fingerprintState.isVerified {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.verifyFingerprint(for: peerID)
|
verificationModel.verifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text(Strings.markVerified)
|
Text(Strings.markVerified)
|
||||||
@@ -211,7 +191,7 @@ struct FingerprintView: View {
|
|||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
} else {
|
} else {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.unverifyFingerprint(for: peerID)
|
verificationModel.unverifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text(Strings.removeVerification)
|
Text(Strings.removeVerification)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeohashPeopleList: View {
|
struct GeohashPeopleList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@EnvironmentObject private var peerListModel: PeerListModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPerson: () -> Void
|
let onTapPerson: () -> Void
|
||||||
@@ -17,7 +17,7 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if peerListModel.geohashPeople.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(Strings.noneNearby)
|
Text(Strings.noneNearby)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
@@ -26,26 +26,16 @@ struct GeohashPeopleList: View {
|
|||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let people = peerListModel.geohashPeople
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
let currentIDs = people.map(\.id)
|
||||||
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 displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
let nonTele = displayIDs.filter { !isTeleportedID($0) }
|
let nonTele = displayIDs.filter { id in
|
||||||
let tele = displayIDs.filter { isTeleportedID($0) }
|
!(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 finalOrder: [String] = nonTele + tele
|
||||||
let firstID = finalOrder.first
|
let firstID = finalOrder.first
|
||||||
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
|
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
|
||||||
@@ -54,39 +44,35 @@ struct GeohashPeopleList: View {
|
|||||||
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
|
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
|
||||||
let person = personByID[pid]!
|
let person = personByID[pid]!
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let isMe = (person.id == myHex)
|
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
||||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
let rowColor: Color = person.isMe ? .orange : assignedColor
|
||||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
|
||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
|
||||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
let (base, suffix) = person.displayName.splitSuffix()
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(isMe ? .bold : .regular)
|
.fontWeight(person.isMe ? .bold : .regular)
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
if !suffix.isEmpty {
|
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)
|
Text(suffix)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
if isMe {
|
if person.isMe {
|
||||||
Text(Strings.youSuffix)
|
Text(Strings.youSuffix)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let me = myHex, person.id != me {
|
if person.isBlocked {
|
||||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help(Strings.blockedTooltip)
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -94,20 +80,29 @@ struct GeohashPeopleList: View {
|
|||||||
.padding(.top, person.id == firstID ? 10 : 0)
|
.padding(.top, person.id == firstID ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if person.id != myHex {
|
if !person.isMe {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: person.id)
|
peerListModel.openGeohashDirectMessage(with: person.id)
|
||||||
onTapPerson()
|
onTapPerson()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if let me = myHex, person.id == me {
|
if person.isMe {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
if person.isBlocked {
|
||||||
if blocked {
|
Button(Strings.unblock) {
|
||||||
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
peerListModel.unblockGeohashUser(
|
||||||
|
pubkeyHexLowercased: person.id,
|
||||||
|
displayName: person.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} 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
|
#endif
|
||||||
struct LocationChannelsSheet: View {
|
struct LocationChannelsSheet: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@EnvironmentObject private var peerListModel: PeerListModel
|
||||||
@ObservedObject private var network = NetworkActivationService.shared
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@State private var customError: String? = nil
|
@State private var customError: String? = nil
|
||||||
@@ -108,9 +106,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
switch manager.permissionState {
|
switch locationChannelsModel.permissionState {
|
||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case .notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { locationChannelsModel.enableLocationChannels() }) {
|
||||||
Text(Strings.requestPermissions)
|
Text(Strings.requestPermissions)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
@@ -120,7 +118,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
case .denied, .restricted:
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(Strings.permissionDenied)
|
Text(Strings.permissionDenied)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
@@ -128,7 +126,7 @@ struct LocationChannelsSheet: View {
|
|||||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case .authorized:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,22 +150,22 @@ struct LocationChannelsSheet: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Refresh channels when opening
|
// Refresh channels when opening
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if locationChannelsModel.permissionState == .authorized {
|
||||||
manager.refreshChannels()
|
locationChannelsModel.refreshChannels()
|
||||||
}
|
}
|
||||||
// Begin periodic refresh while sheet is open
|
// Begin periodic refresh while sheet is open
|
||||||
manager.beginLiveRefresh()
|
locationChannelsModel.beginLiveRefresh()
|
||||||
// Geohash sampling is now managed by ChatViewModel globally
|
// Geohash sampling is now managed by ChatViewModel globally
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
manager.endLiveRefresh()
|
locationChannelsModel.endLiveRefresh()
|
||||||
}
|
}
|
||||||
.onChange(of: manager.permissionState) { newValue in
|
.onChange(of: locationChannelsModel.permissionState) { newValue in
|
||||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
if newValue == .authorized {
|
||||||
manager.refreshChannels()
|
locationChannelsModel.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: manager.availableChannels) { _ in }
|
.onChange(of: locationChannelsModel.availableChannels) { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
@@ -183,20 +181,20 @@ struct LocationChannelsSheet: View {
|
|||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
locationChannelsModel.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
let nearby = manager.availableChannels.filter { $0.level != .building }
|
let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building }
|
||||||
if !nearby.isEmpty {
|
if !nearby.isEmpty {
|
||||||
ForEach(nearby) { channel in
|
ForEach(nearby) { channel in
|
||||||
sectionDivider
|
sectionDivider
|
||||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
let nameBase = locationName(for: channel.level)
|
let nameBase = locationName(for: channel.level)
|
||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
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 subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||||
let highlight = participantCount > 0
|
let highlight = participantCount > 0
|
||||||
channelRow(
|
channelRow(
|
||||||
@@ -206,16 +204,16 @@ struct LocationChannelsSheet: View {
|
|||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
titleBold: highlight,
|
titleBold: highlight,
|
||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||||
manager.select(ChannelID.location(channel))
|
locationChannelsModel.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -235,14 +233,14 @@ struct LocationChannelsSheet: View {
|
|||||||
customTeleportSection
|
customTeleportSection
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
let bookmarkedList = bookmarks.bookmarks
|
let bookmarkedList = locationChannelsModel.bookmarks
|
||||||
if !bookmarkedList.isEmpty {
|
if !bookmarkedList.isEmpty {
|
||||||
sectionDivider
|
sectionDivider
|
||||||
bookmarkedSection(bookmarkedList)
|
bookmarkedSection(bookmarkedList)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if locationChannelsModel.permissionState == .authorized {
|
||||||
sectionDivider
|
sectionDivider
|
||||||
torToggleSection
|
torToggleSection
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -309,10 +307,7 @@ struct LocationChannelsSheet: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
let gh = normalized
|
let gh = normalized
|
||||||
guard isValid else { customError = Strings.invalidGeohash; return }
|
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||||
let level = levelForLength(gh.count)
|
locationChannelsModel.teleport(to: gh)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
|
||||||
manager.markTeleported(for: ch.geohash, true)
|
|
||||||
manager.select(ChannelID.location(ch))
|
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -350,33 +345,33 @@ struct LocationChannelsSheet: View {
|
|||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
let channel = GeohashChannel(level: level, geohash: gh)
|
||||||
let coverage = coverageString(forPrecision: gh.count)
|
let coverage = coverageString(forPrecision: gh.count)
|
||||||
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||||
let name = bookmarks.bookmarkNames[gh]
|
let name = locationChannelsModel.bookmarkNames[gh]
|
||||||
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
let participantCount = peerListModel.participantCount(for: gh)
|
||||||
channelRow(
|
channelRow(
|
||||||
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||||
subtitlePrefix: subtitle,
|
subtitlePrefix: subtitle,
|
||||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||||
isSelected: isSelected(channel),
|
isSelected: locationChannelsModel.isSelected(channel),
|
||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
Button(action: { locationChannelsModel.toggleBookmark(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||||
if !inRegional && !manager.availableChannels.isEmpty {
|
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||||
manager.markTeleported(for: gh, true)
|
locationChannelsModel.markTeleported(for: gh, true)
|
||||||
} else {
|
} else {
|
||||||
manager.markTeleported(for: gh, false)
|
locationChannelsModel.markTeleported(for: gh, false)
|
||||||
}
|
}
|
||||||
manager.select(ChannelID.location(channel))
|
locationChannelsModel.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }
|
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
|
||||||
|
|
||||||
if index < entries.count - 1 {
|
if index < entries.count - 1 {
|
||||||
sectionDivider
|
sectionDivider
|
||||||
@@ -388,14 +383,11 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
|
|
||||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
if case .location(let ch) = manager.selectedChannel {
|
locationChannelsModel.isSelected(channel)
|
||||||
return ch == channel
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isMeshSelected: Bool {
|
private var isMeshSelected: Bool {
|
||||||
if case .mesh = manager.selectedChannel { return true }
|
if case .mesh = locationChannelsModel.selectedChannel { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,17 +445,6 @@ struct LocationChannelsSheet: View {
|
|||||||
let suffix = String(s[idx...])
|
let suffix = String(s[idx...])
|
||||||
return (prefix, suffix)
|
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 {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
@@ -487,8 +468,8 @@ struct LocationChannelsSheet: View {
|
|||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
private var torToggleBinding: Binding<Bool> {
|
private var torToggleBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { network.userTorEnabled },
|
get: { locationChannelsModel.userTorEnabled },
|
||||||
set: { network.setUserTorEnabled($0) }
|
set: { locationChannelsModel.setUserTorEnabled($0) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +589,7 @@ extension LocationChannelsSheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func locationName(for level: GeohashChannelLevel) -> String? {
|
private func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
manager.locationNames[level]
|
locationChannelsModel.locationName(for: level)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LocationNotesView: View {
|
struct LocationNotesView: View {
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
|
||||||
@StateObject private var manager: LocationNotesManager
|
@StateObject private var manager: LocationNotesManager
|
||||||
let geohash: String
|
let geohash: String
|
||||||
|
let senderNickname: String
|
||||||
let onNotesCountChanged: ((Int) -> Void)?
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@State private var draft: String = ""
|
||||||
|
|
||||||
init(
|
init(
|
||||||
geohash: String,
|
geohash: String,
|
||||||
|
senderNickname: String,
|
||||||
onNotesCountChanged: ((Int) -> Void)? = nil,
|
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||||
manager: LocationNotesManager? = nil
|
manager: LocationNotesManager? = nil
|
||||||
) {
|
) {
|
||||||
let gh = geohash.lowercased()
|
let gh = geohash.lowercased()
|
||||||
self.geohash = gh
|
self.geohash = gh
|
||||||
|
self.senderNickname = senderNickname
|
||||||
self.onNotesCountChanged = onNotesCountChanged
|
self.onNotesCountChanged = onNotesCountChanged
|
||||||
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
@@ -113,11 +115,11 @@ struct LocationNotesView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
closeButton
|
closeButton
|
||||||
}
|
}
|
||||||
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
|
||||||
Text(building)
|
Text(building)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(accentGreen)
|
.foregroundColor(accentGreen)
|
||||||
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
|
||||||
Text(block)
|
Text(block)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(accentGreen)
|
.foregroundColor(accentGreen)
|
||||||
@@ -265,7 +267,7 @@ struct LocationNotesView: View {
|
|||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||||
manager.send(content: content, nickname: viewModel.nickname)
|
manager.send(content: content, nickname: senderNickname)
|
||||||
draft = ""
|
draft = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import BitFoundation
|
|||||||
|
|
||||||
struct MediaMessageView: View {
|
struct MediaMessageView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
let media: BitchatMessage.Media
|
let media: BitchatMessage.Media
|
||||||
|
|
||||||
@@ -19,15 +18,15 @@ struct MediaMessageView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let state = mediaSendState(for: message)
|
let state = mediaSendState(for: message)
|
||||||
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
|
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
|
||||||
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
|
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
|
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if message.isPrivate && message.sender == viewModel.nickname,
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
let status = message.deliveryStatus {
|
let status = message.deliveryStatus {
|
||||||
DeliveryStatusView(status: status)
|
DeliveryStatusView(status: status)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
@@ -55,7 +54,7 @@ struct MediaMessageView: View {
|
|||||||
imagePreviewURL = url
|
imagePreviewURL = url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
|
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 280)
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ struct MediaMessageView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let canCancel = isSending && message.sender == viewModel.nickname
|
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
|
||||||
let clamped = progress.map { max(0, min(1, $0)) }
|
let clamped = progress.map { max(0, min(1, $0)) }
|
||||||
return (isSending, isSending ? clamped : nil, canCancel)
|
return (isSending, isSending ? clamped : nil, canCancel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
struct MeshPeerList: View {
|
struct MeshPeerList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@EnvironmentObject private var peerListModel: PeerListModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPeer: (PeerID) -> Void
|
let onTapPeer: (PeerID) -> Void
|
||||||
@@ -19,21 +19,13 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let myPeerID = viewModel.meshService.myPeerID
|
let currentIDs = peerListModel.meshRows.map(\.id)
|
||||||
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 displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
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
|
let peers: [MeshPeerRow] = displayIDs.compactMap { id in
|
||||||
mapped.first(where: { $0.peer.peerID.id == id })
|
peerListModel.meshRows.first(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.allPeers.isEmpty {
|
if peerListModel.meshRows.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(Strings.noneNearby)
|
Text(Strings.noneNearby)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
@@ -44,11 +36,10 @@ struct MeshPeerList: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<peers.count, id: \.self) { idx in
|
ForEach(0..<peers.count, id: \.self) { idx in
|
||||||
let item = peers[idx]
|
let peer = peers[idx]
|
||||||
let peer = item.peer
|
let isMe = peer.isMe
|
||||||
let isMe = item.isMe
|
|
||||||
HStack(spacing: 4) {
|
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
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
@@ -76,8 +67,7 @@ struct MeshPeerList: View {
|
|||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let (base, suffix) = peer.displayName.splitSuffix()
|
||||||
let (base, suffix) = displayName.splitSuffix()
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.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")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -99,19 +89,18 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
if peer.isConnected {
|
if peer.isConnected {
|
||||||
if let icon = item.enc.icon {
|
if let icon = peer.encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
if let fp = viewModel.getFingerprint(for: peer.peerID),
|
if peer.showsVerifiedBadgeWhenOffline {
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.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)
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
@@ -123,7 +112,7 @@ struct MeshPeerList: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Unread message indicator for this peer
|
// Unread message indicator for this peer
|
||||||
if !isMe, item.hasUnread {
|
if peer.hasUnread {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
@@ -132,9 +121,9 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID) }) {
|
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))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -149,10 +138,9 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
.onAppear {
|
.onAppear {
|
||||||
let currentIDs = mapped.map { $0.peer.peerID.id }
|
|
||||||
orderedIDs = currentIDs
|
orderedIDs = currentIDs
|
||||||
}
|
}
|
||||||
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
.onChange(of: currentIDs) { ids in
|
||||||
var newOrder = orderedIDs
|
var newOrder = orderedIDs
|
||||||
newOrder.removeAll { !ids.contains($0) }
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ private struct MessageDisplayItem: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct MessageListView: View {
|
struct MessageListView: View {
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
@EnvironmentObject private var publicChatModel: PublicChatModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@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
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@@ -43,14 +46,14 @@ struct MessageListView: View {
|
|||||||
return windowCountPublic
|
return windowCountPublic
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let messages = viewModel.getMessages(for: privatePeer)
|
let messages = conversationMessages(for: privatePeer)
|
||||||
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
||||||
|
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
"dm:\(peer)"
|
"dm:\(peer)"
|
||||||
} else {
|
} else {
|
||||||
locationManager.selectedChannel.contextKey
|
locationChannelsModel.selectedChannel.contextKey
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -106,12 +109,12 @@ struct MessageListView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.onOpenURL(perform: handleOpenURL)
|
.onOpenURL(perform: handleOpenURL)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
viewModel.sendMessage("/clear")
|
conversationUIModel.clearCurrentConversation()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(on: proxy)
|
scrollToBottom(on: proxy)
|
||||||
@@ -119,13 +122,13 @@ struct MessageListView: View {
|
|||||||
.onChange(of: privatePeer) { _ in
|
.onChange(of: privatePeer) { _ in
|
||||||
scrollToBottom(on: proxy)
|
scrollToBottom(on: proxy)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.messages.count) { _ in
|
.onChange(of: publicChatModel.messages.count) { _ in
|
||||||
onMessagesChange(proxy: proxy)
|
onMessagesChange(proxy: proxy)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
.onChange(of: privateMessageCount(for: privatePeer)) { _ in
|
||||||
onPrivateChatsChange(proxy: proxy)
|
onPrivateChatsChange(proxy: proxy)
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
.onChange(of: locationChannelsModel.selectedChannel) { newChannel in
|
||||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
@@ -143,13 +146,7 @@ struct MessageListView: View {
|
|||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.isGeoChat {
|
privateConversationModel.openConversation(for: peerID)
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewModel.startPrivateChat(with: peerID)
|
|
||||||
}
|
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
}
|
}
|
||||||
@@ -158,25 +155,18 @@ struct MessageListView: View {
|
|||||||
|
|
||||||
Button("content.actions.hug") {
|
Button("content.actions.hug") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
viewModel.sendMessage("/hug @\(sender)")
|
conversationUIModel.sendHug(to: sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("content.actions.slap") {
|
Button("content.actions.slap") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
viewModel.sendMessage("/slap @\(sender)")
|
conversationUIModel.sendSlap(to: sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
Button("content.actions.block", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("common.cancel", role: .cancel) {}
|
Button("common.cancel", role: .cancel) {}
|
||||||
@@ -185,14 +175,14 @@ struct MessageListView: View {
|
|||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = privatePeer {
|
if let peerID = privatePeer {
|
||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
privateConversationModel.markMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
privateConversationModel.markMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
privateConversationModel.markMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +212,7 @@ private extension MessageListView {
|
|||||||
Group {
|
Group {
|
||||||
if message.sender == "system" {
|
if message.sender == "system" {
|
||||||
systemMessageRow(message)
|
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)
|
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
||||||
} else {
|
} else {
|
||||||
TextMessageView(message: message)
|
TextMessageView(message: message)
|
||||||
@@ -232,7 +222,7 @@ private extension MessageListView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -246,7 +236,7 @@ private extension MessageListView {
|
|||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
"dm:\(peer)"
|
"dm:\(peer)"
|
||||||
} else {
|
} else {
|
||||||
locationManager.selectedChannel.contextKey
|
locationChannelsModel.selectedChannel.contextKey
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let preserveID = "\(contextKey)|\(message.id)"
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
@@ -278,15 +268,12 @@ private extension MessageListView {
|
|||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID
|
selectedMessageSenderID = peerID
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
selectedMessageSender = conversationUIModel.senderDisplayName(
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
for: peerID,
|
||||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
fallbackMessages: conversationMessages(for: privatePeer)
|
||||||
selectedMessageSender = name
|
)
|
||||||
} else {
|
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
if conversationUIModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||||
selectedMessageSender = nil
|
selectedMessageSender = nil
|
||||||
selectedMessageSenderID = nil
|
selectedMessageSenderID = nil
|
||||||
} else {
|
} else {
|
||||||
@@ -297,26 +284,7 @@ private extension MessageListView {
|
|||||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||||
|
locationChannelsModel.openLocationChannel(for: gh)
|
||||||
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))
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
@@ -337,20 +305,21 @@ private extension MessageListView {
|
|||||||
|
|
||||||
var targetPeerID: String? {
|
var targetPeerID: String? {
|
||||||
if let peer = privatePeer,
|
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)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
if let last = viewModel.messages.suffix(300).last?.id {
|
if let last = publicChatModel.messages.suffix(300).last?.id {
|
||||||
return "\(locationManager.selectedChannel.contextKey)|\(last)"
|
return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)"
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
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
|
// 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
|
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||||
return
|
return
|
||||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||||
@@ -359,8 +328,8 @@ private extension MessageListView {
|
|||||||
|
|
||||||
func scrollIfNeeded(date: Date) {
|
func scrollIfNeeded(date: Date) {
|
||||||
lastScrollTime = date
|
lastScrollTime = date
|
||||||
let contextKey = locationManager.selectedChannel.contextKey
|
let contextKey = locationChannelsModel.selectedChannel.contextKey
|
||||||
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||||
proxy.scrollTo(target, anchor: .bottom)
|
proxy.scrollTo(target, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,12 +351,14 @@ private extension MessageListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
let messages = privateInboxModel.messages(for: peerID)
|
||||||
|
|
||||||
// If the newest private message is from me, always scroll
|
// 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
|
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -428,11 +399,22 @@ private extension MessageListView {
|
|||||||
isAtBottom = true
|
isAtBottom = true
|
||||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
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)
|
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 {
|
private extension ChannelID {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ struct ImageWrapper: View {
|
|||||||
|
|
||||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||||
struct QRScanView: View {
|
struct QRScanView: View {
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
var isActive: Bool = true
|
var isActive: Bool = true
|
||||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||||
@State private var input = ""
|
@State private var input = ""
|
||||||
@@ -135,17 +135,15 @@ struct QRScanView: View {
|
|||||||
// Deduplicate: ignore if we just processed this exact QR code
|
// Deduplicate: ignore if we just processed this exact QR code
|
||||||
guard code != lastValid else { return }
|
guard code != lastValid else { return }
|
||||||
|
|
||||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
switch verificationModel.verifyScannedPayload(code) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
case .requested:
|
||||||
if ok {
|
|
||||||
// Successfully initiated verification; remember this QR to prevent re-scanning
|
// Successfully initiated verification; remember this QR to prevent re-scanning
|
||||||
lastValid = code
|
lastValid = code
|
||||||
// Close scanner and return to "My QR" view
|
// Close scanner and return to "My QR" view
|
||||||
onSuccess?()
|
onSuccess?()
|
||||||
}
|
case .notFound, .invalid:
|
||||||
// If !ok, peer not found or already pending - don't set lastValid so user can retry
|
// Ignore invalid/no-match reads and keep scanning
|
||||||
} else {
|
break
|
||||||
// ignore invalid reads; continue scanning
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
@@ -163,17 +161,15 @@ struct QRScanView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
switch verificationModel.verifyScannedPayload(input) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
case .requested(let nickname):
|
||||||
if ok {
|
result = Strings.requested(nickname)
|
||||||
result = Strings.requested(qr.nickname)
|
|
||||||
lastValid = input
|
lastValid = input
|
||||||
// Close scanner and return to "My QR" view
|
// Close scanner and return to "My QR" view
|
||||||
onSuccess?()
|
onSuccess?()
|
||||||
} else {
|
case .notFound:
|
||||||
result = Strings.notFound
|
result = Strings.notFound
|
||||||
}
|
case .invalid:
|
||||||
} else {
|
|
||||||
result = Strings.invalid
|
result = Strings.invalid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +278,7 @@ struct CameraScannerView: UIViewRepresentable {
|
|||||||
|
|
||||||
// Combined sheet: shows my QR by default with a button to scan instead
|
// Combined sheet: shows my QR by default with a button to scan instead
|
||||||
struct VerificationSheetView: View {
|
struct VerificationSheetView: View {
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@State private var showingScanner = false
|
@State private var showingScanner = false
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@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 accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
@@ -333,14 +324,14 @@ struct VerificationSheetView: View {
|
|||||||
QRScanView(isActive: showingScanner, onSuccess: {
|
QRScanView(isActive: showingScanner, onSuccess: {
|
||||||
showingScanner = false
|
showingScanner = false
|
||||||
})
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(verificationModel)
|
||||||
.frame(height: 280)
|
.frame(height: 280)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
#else
|
#else
|
||||||
QRScanView(onSuccess: {
|
QRScanView(onSuccess: {
|
||||||
showingScanner = false
|
showingScanner = false
|
||||||
})
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(verificationModel)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -348,8 +339,7 @@ struct VerificationSheetView: View {
|
|||||||
.background(boxColor)
|
.background(boxColor)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
} else {
|
} else {
|
||||||
let qr = myQRString()
|
MyQRView(qrString: verificationModel.myQRString())
|
||||||
MyQRView(qrString: qr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -373,10 +363,9 @@ struct VerificationSheetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Remove verification for selected peer (if verified)
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
if let pid = viewModel.selectedPrivateChatPeer,
|
if let peerID = verificationModel.selectedPeerID,
|
||||||
let fp = viewModel.getFingerprint(for: pid),
|
verificationModel.isVerified(peerID: peerID) {
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
|
||||||
Label("remove verification", systemImage: "minus.circle")
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.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
|
// MARK: - Public Timeline Status Tests
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -67,6 +67,135 @@ struct ChatViewModelInitializationTests {
|
|||||||
// Nickname should be set during init
|
// Nickname should be set during init
|
||||||
#expect(!transport.myNickname.isEmpty)
|
#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
|
// 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
|
// MARK: - Timeline Cap Tests
|
||||||
|
|
||||||
struct ChatViewModelTimelineCapTests {
|
struct ChatViewModelTimelineCapTests {
|
||||||
@@ -159,7 +398,7 @@ struct ChatViewModelReceivingTests {
|
|||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didReceiveMessage_callsDelegate() async {
|
func didReceiveMessage_callsDelegate() async {
|
||||||
let (_, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: "msg-001",
|
id: "msg-001",
|
||||||
@@ -181,7 +420,7 @@ struct ChatViewModelReceivingTests {
|
|||||||
|
|
||||||
// Message may or may not appear due to rate limiting/pipeline batching
|
// Message may or may not appear due to rate limiting/pipeline batching
|
||||||
// The important thing is no crash and delegate was called
|
// The important thing is no crash and delegate was called
|
||||||
#expect(transport.delegate != nil)
|
#expect(transport.delegate === viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@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
|
// MARK: - Rate Limiting Tests
|
||||||
|
|
||||||
struct ChatViewModelRateLimitingTests {
|
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
|
// MARK: - Peer Connection Tests
|
||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
struct ChatViewModelPeerTests {
|
||||||
@@ -275,6 +713,46 @@ struct ChatViewModelPeerTests {
|
|||||||
|
|
||||||
#expect(transport.isPeerConnected(peerID))
|
#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
|
// MARK: - Deduplication Integration Tests
|
||||||
@@ -328,7 +806,7 @@ struct ChatViewModelPrivateChatTests {
|
|||||||
|
|
||||||
// The message routing depends on connection state and other factors
|
// The message routing depends on connection state and other factors
|
||||||
// At minimum, it should not crash
|
// 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
|
context.nicknameToPeerID["alice"] = peerID
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
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")
|
processor.process("/msg @alice hello there")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +72,15 @@ struct CommandProcessorTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test func whoInMeshListsSortedPeerNicknames() async {
|
@Test func whoInMeshListsSortedPeerNicknames() async {
|
||||||
let identityManager = MockIdentityManager(MockKeychain())
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
transport.peerNicknames = [
|
transport.peerNicknames = [
|
||||||
PeerID(str: "b"): "bob",
|
PeerID(str: "b"): "bob",
|
||||||
PeerID(str: "a"): "alice"
|
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")
|
processor.process("/who")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ struct CommandProcessorTests {
|
|||||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||||
|
|
||||||
let result = await withSelectedChannel(channel) {
|
let result = await withSelectedChannel(channel, context: context) {
|
||||||
processor.process("/who")
|
processor.process("/who")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ struct CommandProcessorTests {
|
|||||||
]
|
]
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||||
|
|
||||||
let result = await withSelectedChannel(.mesh) {
|
let result = await withSelectedChannel(.mesh, context: context) {
|
||||||
processor.process("/clear")
|
processor.process("/clear")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ struct CommandProcessorTests {
|
|||||||
let context = MockCommandContextProvider()
|
let context = MockCommandContextProvider()
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||||
|
|
||||||
let result = await withSelectedChannel(.mesh) {
|
let result = await withSelectedChannel(.mesh, context: context) {
|
||||||
processor.process("/clear")
|
processor.process("/clear")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ struct CommandProcessorTests {
|
|||||||
transport.peerNicknames[peerID] = "Bob"
|
transport.peerNicknames[peerID] = "Bob"
|
||||||
let processor = CommandProcessor(contextProvider: context, 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("/hug @bob")
|
processor.process("/hug @bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ struct CommandProcessorTests {
|
|||||||
context.nicknameToPeerID["bob"] = peerID
|
context.nicknameToPeerID["bob"] = peerID
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
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")
|
processor.process("/slap @bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ struct CommandProcessorTests {
|
|||||||
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
|
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
|
||||||
let processor = CommandProcessor(contextProvider: context, 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("/block")
|
processor.process("/block")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +250,7 @@ struct CommandProcessorTests {
|
|||||||
context.nicknameToPeerID["bob"] = peerID
|
context.nicknameToPeerID["bob"] = peerID
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
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")
|
processor.process("/block @bob")
|
||||||
}
|
}
|
||||||
switch blockResult {
|
switch blockResult {
|
||||||
@@ -260,7 +261,7 @@ struct CommandProcessorTests {
|
|||||||
}
|
}
|
||||||
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
|
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||||
|
|
||||||
let unblockResult = await withSelectedChannel(.mesh) {
|
let unblockResult = await withSelectedChannel(.mesh, context: context) {
|
||||||
processor.process("/unblock bob")
|
processor.process("/unblock bob")
|
||||||
}
|
}
|
||||||
switch unblockResult {
|
switch unblockResult {
|
||||||
@@ -279,7 +280,7 @@ struct CommandProcessorTests {
|
|||||||
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
|
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
|
||||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
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")
|
processor.process("/block carol")
|
||||||
}
|
}
|
||||||
switch blockResult {
|
switch blockResult {
|
||||||
@@ -290,7 +291,7 @@ struct CommandProcessorTests {
|
|||||||
}
|
}
|
||||||
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
#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")
|
processor.process("/unblock @carol")
|
||||||
}
|
}
|
||||||
switch unblockResult {
|
switch unblockResult {
|
||||||
@@ -305,14 +306,15 @@ struct CommandProcessorTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||||
let identityManager = MockIdentityManager(MockKeychain())
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
let processor = CommandProcessor(
|
let processor = CommandProcessor(
|
||||||
contextProvider: MockCommandContextProvider(),
|
contextProvider: context,
|
||||||
meshService: MockTransport(),
|
meshService: MockTransport(),
|
||||||
identityManager: identityManager
|
identityManager: identityManager
|
||||||
)
|
)
|
||||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
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")
|
processor.process("/fav alice")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,21 +327,31 @@ struct CommandProcessorTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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
|
let originalChannel = LocationChannelManager.shared.selectedChannel
|
||||||
await setSelectedChannel(channel)
|
let originalContextChannel = context?.activeChannel
|
||||||
|
await setSelectedChannel(channel, context: context)
|
||||||
do {
|
do {
|
||||||
let result = try work()
|
let result = try work()
|
||||||
await setSelectedChannel(originalChannel)
|
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
await setSelectedChannel(originalChannel)
|
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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)
|
LocationChannelManager.shared.select(channel)
|
||||||
for _ in 0..<40 {
|
for _ in 0..<40 {
|
||||||
if LocationChannelManager.shared.selectedChannel == channel {
|
if LocationChannelManager.shared.selectedChannel == channel {
|
||||||
@@ -363,6 +375,7 @@ struct CommandProcessorTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockCommandContextProvider: CommandContextProvider {
|
private final class MockCommandContextProvider: CommandContextProvider {
|
||||||
var nickname: String
|
var nickname: String
|
||||||
|
var activeChannel: ChannelID = .mesh
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var blockedUsers: Set<String> = []
|
var blockedUsers: Set<String> = []
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|||||||
@@ -12,26 +12,12 @@ import CoreBluetooth
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("Fragmentation Tests", .serialized)
|
||||||
struct FragmentationTests {
|
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")
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
let ble = BLEService(
|
let ble = makeBLEService()
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager,
|
|
||||||
initializeBluetoothManagers: false
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -54,7 +40,7 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// 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.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
@@ -62,12 +48,7 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Duplicate fragment does not break reassembly")
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
let ble = BLEService(
|
let ble = makeBLEService()
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager,
|
|
||||||
initializeBluetoothManagers: false
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -89,7 +70,7 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// 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.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
@@ -97,12 +78,7 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Max-sized file transfer survives reassembly")
|
@Test("Max-sized file transfer survives reassembly")
|
||||||
func maxSizedFileTransferSurvivesReassembly() async throws {
|
func maxSizedFileTransferSurvivesReassembly() async throws {
|
||||||
let ble = BLEService(
|
let ble = makeBLEService()
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager,
|
|
||||||
initializeBluetoothManagers: false
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -131,14 +107,13 @@ struct FragmentationTests {
|
|||||||
#expect(!fragments.isEmpty)
|
#expect(!fragments.isEmpty)
|
||||||
|
|
||||||
for (i, fragment) in fragments.enumerated() {
|
for (i, fragment) in fragments.enumerated() {
|
||||||
let delay = 5 * Double(i) * 0.001
|
if i > 0 {
|
||||||
Task {
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
try await sleep(delay)
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
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")
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
#expect(message.content.hasPrefix("[file]"))
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
@@ -154,12 +129,7 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(
|
let ble = makeBLEService()
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager,
|
|
||||||
initializeBluetoothManagers: false
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -184,11 +154,10 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in corrupted.enumerated() {
|
for (i, fragment) in corrupted.enumerated() {
|
||||||
let delay = 5 * Double(i) * 0.001
|
if i > 0 {
|
||||||
Task {
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
try await sleep(delay)
|
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
||||||
}
|
}
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
@@ -200,6 +169,19 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension 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
|
/// Thread-safe delegate that supports awaiting message delivery
|
||||||
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManagerTests: XCTestCase {
|
final class NostrRelayManagerTests: XCTestCase {
|
||||||
|
private let expectedDefaultRelayCount = 4
|
||||||
|
|
||||||
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
|
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
|
||||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
context.manager.connect()
|
context.manager.connect()
|
||||||
|
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
context.sessionFactory.requestedURLs.count == 5 &&
|
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||||
context.manager.relays.allSatisfy(\.isConnected)
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
@@ -27,7 +29,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
context.permissionSubject.send(.authorized)
|
context.permissionSubject.send(.authorized)
|
||||||
|
|
||||||
let defaultRelaysConnected = await waitUntil {
|
let defaultRelaysConnected = await waitUntil {
|
||||||
context.manager.getRelayStatuses().count == 5 &&
|
context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount &&
|
||||||
context.manager.relays.allSatisfy(\.isConnected)
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(defaultRelaysConnected)
|
XCTAssertTrue(defaultRelaysConnected)
|
||||||
@@ -38,7 +40,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
context.manager.getRelayStatuses().isEmpty
|
context.manager.getRelayStatuses().isEmpty
|
||||||
}
|
}
|
||||||
XCTAssertTrue(defaultRelaysRemoved)
|
XCTAssertTrue(defaultRelaysRemoved)
|
||||||
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
|
XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount)
|
||||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,26 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
context.torWaiter.resolve(true)
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
let connectedAfterTorReady = await waitUntil {
|
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)
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(connectedAfterTorReady)
|
XCTAssertTrue(connectedAfterTorReady)
|
||||||
@@ -87,6 +108,24 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(sentAfterTorReady)
|
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 {
|
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
|
||||||
let relayURL = "wss://queue-flush.example"
|
let relayURL = "wss://queue-flush.example"
|
||||||
let context = makeContext(
|
let context = makeContext(
|
||||||
@@ -179,6 +218,26 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
|
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 {
|
func test_subscribe_coalescesRapidDuplicateRequests() async {
|
||||||
let relayURL = "wss://subscribe.example"
|
let relayURL = "wss://subscribe.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
@@ -197,6 +256,63 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
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 {
|
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
|
||||||
let relayURL = "wss://tor-subscribe.example"
|
let relayURL = "wss://tor-subscribe.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -239,7 +355,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
|
|
||||||
context.permissionSubject.send(.authorized)
|
context.permissionSubject.send(.authorized)
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
context.sessionFactory.allConnections.count == 5 &&
|
context.sessionFactory.allConnections.count == self.expectedDefaultRelayCount &&
|
||||||
context.manager.relays.allSatisfy(\.isConnected)
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
@@ -810,6 +926,7 @@ private final class MutableBool {
|
|||||||
|
|
||||||
private final class MockTorWaiter {
|
private final class MockTorWaiter {
|
||||||
private var completions: [(Bool) -> Void] = []
|
private var completions: [(Bool) -> Void] = []
|
||||||
|
private(set) var awaitCallCount = 0
|
||||||
var isReady: Bool
|
var isReady: Bool
|
||||||
|
|
||||||
init(isReady: Bool) {
|
init(isReady: Bool) {
|
||||||
@@ -817,6 +934,7 @@ private final class MockTorWaiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func await(completion: @escaping (Bool) -> Void) {
|
func await(completion: @escaping (Bool) -> Void) {
|
||||||
|
awaitCallCount += 1
|
||||||
completions.append(completion)
|
completions.append(completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,151 @@ private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockT
|
|||||||
return (viewModel, transport, identityManager)
|
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
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func mount<V: View>(_ view: V) -> AnyObject {
|
private func mount<V: View>(_ view: V) -> AnyObject {
|
||||||
@@ -112,11 +257,13 @@ private func makeTemporaryImageURL() throws -> URL {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("View Smoke Tests", .serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ViewSmokeTests {
|
struct ViewSmokeTests {
|
||||||
@Test
|
@Test
|
||||||
func fingerprintView_renders_verifiedAndPendingStates() async {
|
func fingerprintView_renders_verifiedAndPendingStates() async {
|
||||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
let verifiedPeer = PeerID(str: "0102030405060708")
|
let verifiedPeer = PeerID(str: "0102030405060708")
|
||||||
let pendingPeer = PeerID(str: "1112131415161718")
|
let pendingPeer = PeerID(str: "1112131415161718")
|
||||||
let verifiedFingerprint = String(repeating: "ab", count: 32)
|
let verifiedFingerprint = String(repeating: "ab", count: 32)
|
||||||
@@ -131,11 +278,11 @@ struct ViewSmokeTests {
|
|||||||
|
|
||||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||||
|
|
||||||
let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer)
|
let verifiedView = FingerprintView(peerID: verifiedPeer)
|
||||||
let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer)
|
.environmentObject(featureModels.verificationModel)
|
||||||
|
let pendingView = FingerprintView(peerID: pendingPeer)
|
||||||
|
.environmentObject(featureModels.verificationModel)
|
||||||
|
|
||||||
_ = verifiedView.body
|
|
||||||
_ = pendingView.body
|
|
||||||
_ = mount(verifiedView)
|
_ = mount(verifiedView)
|
||||||
_ = mount(pendingView)
|
_ = mount(pendingView)
|
||||||
|
|
||||||
@@ -145,13 +292,14 @@ struct ViewSmokeTests {
|
|||||||
@Test
|
@Test
|
||||||
func verificationViews_renderCoreBranches() throws {
|
func verificationViews_renderCoreBranches() throws {
|
||||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
let peerID = PeerID(str: "2122232425262728")
|
let peerID = PeerID(str: "2122232425262728")
|
||||||
let fingerprint = String(repeating: "cd", count: 32)
|
let fingerprint = String(repeating: "cd", count: 32)
|
||||||
var isPresented = true
|
var isPresented = true
|
||||||
|
|
||||||
transport.peerFingerprints[peerID] = fingerprint
|
transport.peerFingerprints[peerID] = fingerprint
|
||||||
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
|
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
|
||||||
viewModel.selectedPrivateChatPeer = peerID
|
featureModels.privateConversationModel.startConversation(with: peerID)
|
||||||
viewModel.verifiedFingerprints.insert(fingerprint)
|
viewModel.verifiedFingerprints.insert(fingerprint)
|
||||||
|
|
||||||
let image = try makeCGImage()
|
let image = try makeCGImage()
|
||||||
@@ -173,35 +321,36 @@ struct ViewSmokeTests {
|
|||||||
set: { isPresented = $0 }
|
set: { isPresented = $0 }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(featureModels.verificationModel)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func meshPeerList_renders_emptyAndPopulatedStates() async {
|
func meshPeerList_renders_emptyAndPopulatedStates() async {
|
||||||
let (viewModel, transport, identityManager) = makeSmokeViewModel()
|
let (viewModel, transport, identityManager) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
let connectedPeer = PeerID(str: "3132333435363738")
|
let connectedPeer = PeerID(str: "3132333435363738")
|
||||||
let blockedPeer = PeerID(str: "4142434445464748")
|
let blockedPeer = PeerID(str: "4142434445464748")
|
||||||
let blockedFingerprint = String(repeating: "ef", count: 32)
|
let blockedFingerprint = String(repeating: "ef", count: 32)
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
MeshPeerList(
|
MeshPeerList(
|
||||||
viewModel: viewModel,
|
|
||||||
textColor: .green,
|
textColor: .green,
|
||||||
secondaryTextColor: .gray,
|
secondaryTextColor: .gray,
|
||||||
onTapPeer: { _ in },
|
onTapPeer: { _ in },
|
||||||
onToggleFavorite: { _ in },
|
onToggleFavorite: { _ in },
|
||||||
onShowFingerprint: { _ in }
|
onShowFingerprint: { _ in }
|
||||||
)
|
)
|
||||||
|
.environmentObject(featureModels.peerListModel)
|
||||||
)
|
)
|
||||||
_ = MeshPeerList(
|
_ = MeshPeerList(
|
||||||
viewModel: viewModel,
|
|
||||||
textColor: .green,
|
textColor: .green,
|
||||||
secondaryTextColor: .gray,
|
secondaryTextColor: .gray,
|
||||||
onTapPeer: { _ in },
|
onTapPeer: { _ in },
|
||||||
onToggleFavorite: { _ in },
|
onToggleFavorite: { _ in },
|
||||||
onShowFingerprint: { _ in }
|
onShowFingerprint: { _ in }
|
||||||
).body
|
)
|
||||||
|
.environmentObject(featureModels.peerListModel)
|
||||||
|
|
||||||
transport.peerFingerprints[blockedPeer] = blockedFingerprint
|
transport.peerFingerprints[blockedPeer] = blockedFingerprint
|
||||||
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
|
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
|
||||||
@@ -214,13 +363,13 @@ struct ViewSmokeTests {
|
|||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
MeshPeerList(
|
MeshPeerList(
|
||||||
viewModel: viewModel,
|
|
||||||
textColor: .green,
|
textColor: .green,
|
||||||
secondaryTextColor: .gray,
|
secondaryTextColor: .gray,
|
||||||
onTapPeer: { _ in },
|
onTapPeer: { _ in },
|
||||||
onToggleFavorite: { _ in },
|
onToggleFavorite: { _ in },
|
||||||
onShowFingerprint: { _ in }
|
onShowFingerprint: { _ in }
|
||||||
)
|
)
|
||||||
|
.environmentObject(featureModels.peerListModel)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
|
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
|
||||||
@@ -229,10 +378,11 @@ struct ViewSmokeTests {
|
|||||||
@Test
|
@Test
|
||||||
func commandSuggestionsAndLocationViews_render() {
|
func commandSuggestionsAndLocationViews_render() {
|
||||||
let (viewModel, _, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
|
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
|
||||||
var messageText = "/f"
|
var messageText = "/f"
|
||||||
|
|
||||||
LocationChannelManager.shared.select(.location(channel))
|
featureModels.locationChannelsModel.select(.location(channel))
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
CommandSuggestionsView(
|
CommandSuggestionsView(
|
||||||
@@ -244,22 +394,25 @@ struct ViewSmokeTests {
|
|||||||
backgroundColor: .black,
|
backgroundColor: .black,
|
||||||
secondaryTextColor: .gray
|
secondaryTextColor: .gray
|
||||||
)
|
)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(featureModels.privateConversationModel)
|
||||||
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
LocationChannelsSheet(isPresented: .constant(true))
|
LocationChannelsSheet(isPresented: .constant(true))
|
||||||
.environmentObject(viewModel)
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
|
.environmentObject(featureModels.peerListModel)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(messageText == "/f")
|
#expect(messageText == "/f")
|
||||||
LocationChannelManager.shared.select(.mesh)
|
featureModels.locationChannelsModel.select(.mesh)
|
||||||
LocationChannelManager.shared.endLiveRefresh()
|
featureModels.locationChannelsModel.endLiveRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||||
let (viewModel, _, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
|
|
||||||
let noRelayManager = LocationNotesManager(
|
let noRelayManager = LocationNotesManager(
|
||||||
geohash: "u4pruydq",
|
geohash: "u4pruydq",
|
||||||
@@ -302,12 +455,20 @@ struct ViewSmokeTests {
|
|||||||
eose?()
|
eose?()
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
LocationNotesView(geohash: "u4pruydq", manager: noRelayManager)
|
LocationNotesView(
|
||||||
.environmentObject(viewModel)
|
geohash: "u4pruydq",
|
||||||
|
senderNickname: viewModel.nickname,
|
||||||
|
manager: noRelayManager
|
||||||
|
)
|
||||||
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
)
|
)
|
||||||
_ = mount(
|
_ = mount(
|
||||||
LocationNotesView(geohash: "u4pruydq", manager: loadedManager)
|
LocationNotesView(
|
||||||
.environmentObject(viewModel)
|
geohash: "u4pruydq",
|
||||||
|
senderNickname: viewModel.nickname,
|
||||||
|
manager: loadedManager
|
||||||
|
)
|
||||||
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(loadedManager.notes.count == 1)
|
#expect(loadedManager.notes.count == 1)
|
||||||
@@ -350,11 +511,34 @@ struct ViewSmokeTests {
|
|||||||
#expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning")
|
#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
|
@Test
|
||||||
func geohashAndTextMessageViews_renderCoreBranches() {
|
func geohashAndTextMessageViews_renderCoreBranches() {
|
||||||
let (viewModel, _, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
let geohashPeopleList = GeohashPeopleList(
|
let geohashPeopleList = GeohashPeopleList(
|
||||||
viewModel: viewModel,
|
|
||||||
textColor: .green,
|
textColor: .green,
|
||||||
secondaryTextColor: .gray,
|
secondaryTextColor: .gray,
|
||||||
onTapPerson: {}
|
onTapPerson: {}
|
||||||
@@ -377,10 +561,10 @@ struct ViewSmokeTests {
|
|||||||
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
|
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = geohashPeopleList.body
|
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
|
||||||
_ = mount(geohashPeopleList)
|
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
|
||||||
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel))
|
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel))
|
||||||
_ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel))
|
_ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel))
|
||||||
|
|
||||||
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
|
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
|
||||||
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
|
#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