mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:05:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1aac584e | ||
|
|
3eb4f2bd72 | ||
|
|
193cfdc06a | ||
|
|
ffa0d7aa4f | ||
|
|
9e84f5e822 | ||
|
|
df36b19afe | ||
|
|
ab0da61533 | ||
|
|
3be8fbf1c4 | ||
|
|
764f016d17 | ||
|
|
a6cb8872fb | ||
|
|
3daf02732a | ||
|
|
0d1cdc7644 | ||
|
|
43eef0d49c | ||
|
|
bafa461f26 | ||
|
|
602d2316ef | ||
|
|
c60eff2c11 |
@@ -27,10 +27,10 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v3
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
case background
|
||||
}
|
||||
|
||||
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||
case willStart
|
||||
case willRestart
|
||||
case didBecomeReady
|
||||
case preferenceChanged
|
||||
}
|
||||
|
||||
enum AppEvent: Sendable, Equatable {
|
||||
case launched
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
case nostrRelayConnectionChanged(Bool)
|
||||
case terminationRequested
|
||||
}
|
||||
|
||||
actor AppEventStream {
|
||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||
|
||||
func stream() -> AsyncStream<AppEvent> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
continuations[id] = continuation
|
||||
continuation.onTermination = { [id] _ in
|
||||
Task {
|
||||
await self.removeContinuation(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ event: AppEvent) {
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
for continuation in continuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
continuations.removeAll()
|
||||
}
|
||||
|
||||
private func removeContinuation(_ id: UUID) {
|
||||
continuations.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
struct PeerHandle: Sendable, Identifiable {
|
||||
let id: String
|
||||
let routingPeerID: PeerID
|
||||
let displayName: String?
|
||||
let noisePublicKeyHex: String?
|
||||
let nostrPublicKey: String?
|
||||
}
|
||||
|
||||
extension PeerHandle: Equatable {
|
||||
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerHandle: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum ConversationID: Hashable, Sendable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case direct(PeerHandle)
|
||||
|
||||
init(channelID: ChannelID) {
|
||||
switch channelID {
|
||||
case .mesh:
|
||||
self = .mesh
|
||||
case .location(let channel):
|
||||
self = .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class IdentityResolver {
|
||||
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
|
||||
private var handlesByNoiseKey: [String: PeerHandle] = [:]
|
||||
private var handlesByNostrKey: [String: PeerHandle] = [:]
|
||||
|
||||
func register(peers: [BitchatPeer]) {
|
||||
for peer in peers {
|
||||
_ = register(peer: peer)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func register(peer: BitchatPeer) -> PeerHandle {
|
||||
let handle = buildHandle(
|
||||
routingPeerID: peer.peerID,
|
||||
displayName: peer.displayName,
|
||||
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
|
||||
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
|
||||
)
|
||||
cache(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
|
||||
if let handle = handlesByRoutingPeerID[peerID] {
|
||||
return handle
|
||||
}
|
||||
|
||||
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
|
||||
return handle
|
||||
}
|
||||
|
||||
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
|
||||
return handle
|
||||
}
|
||||
|
||||
let handle = buildHandle(
|
||||
routingPeerID: peerID,
|
||||
displayName: displayName,
|
||||
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
|
||||
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
|
||||
)
|
||||
cache(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
private func buildHandle(
|
||||
routingPeerID: PeerID,
|
||||
displayName: String?,
|
||||
noisePublicKeyHex: String?,
|
||||
nostrPublicKey: String?
|
||||
) -> PeerHandle {
|
||||
let canonicalID: String
|
||||
if let noisePublicKeyHex {
|
||||
canonicalID = "noise:\(noisePublicKeyHex)"
|
||||
} else if let nostrPublicKey {
|
||||
canonicalID = "nostr:\(nostrPublicKey)"
|
||||
} else {
|
||||
canonicalID = "mesh:\(routingPeerID.id)"
|
||||
}
|
||||
|
||||
let normalizedDisplayName: String?
|
||||
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
normalizedDisplayName = displayName
|
||||
} else {
|
||||
normalizedDisplayName = nil
|
||||
}
|
||||
|
||||
return PeerHandle(
|
||||
id: canonicalID,
|
||||
routingPeerID: routingPeerID,
|
||||
displayName: normalizedDisplayName,
|
||||
noisePublicKeyHex: noisePublicKeyHex,
|
||||
nostrPublicKey: nostrPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
|
||||
guard let nostrPublicKey else { return nil }
|
||||
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func cache(_ handle: PeerHandle) {
|
||||
handlesByRoutingPeerID[handle.routingPeerID] = handle
|
||||
if let noisePublicKeyHex = handle.noisePublicKeyHex {
|
||||
handlesByNoiseKey[noisePublicKeyHex] = handle
|
||||
}
|
||||
if let nostrPublicKey = handle.nostrPublicKey {
|
||||
handlesByNostrKey[nostrPublicKey] = handle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ConversationStore: ObservableObject {
|
||||
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||
@Published private(set) var selectedConversationID: ConversationID = .mesh
|
||||
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
|
||||
|
||||
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
|
||||
|
||||
func setActiveChannel(_ channelID: ChannelID) {
|
||||
activeChannel = channelID
|
||||
if selectedPrivatePeerID == nil {
|
||||
selectedConversationID = ConversationID(channelID: channelID)
|
||||
}
|
||||
}
|
||||
|
||||
func setSelectedPeerID(
|
||||
_ peerID: PeerID?,
|
||||
activeChannel: ChannelID,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
self.activeChannel = activeChannel
|
||||
selectedPrivatePeerID = peerID
|
||||
|
||||
if let peerID {
|
||||
selectedConversationID = directConversationID(
|
||||
for: peerID,
|
||||
identityResolver: identityResolver
|
||||
)
|
||||
} else {
|
||||
selectedConversationID = ConversationID(channelID: activeChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
|
||||
replaceMessages(messages, for: ConversationID(channelID: channelID))
|
||||
}
|
||||
|
||||
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
|
||||
setActiveChannel(activeChannel)
|
||||
replaceMessages(messages, for: activeChannel)
|
||||
}
|
||||
|
||||
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
|
||||
messagesByConversation[conversationID] ?? []
|
||||
}
|
||||
|
||||
func directMessages(
|
||||
for peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) -> [BitchatMessage] {
|
||||
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
|
||||
}
|
||||
|
||||
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
|
||||
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
for (conversationID, handle) in directHandlesByConversation {
|
||||
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
|
||||
}
|
||||
|
||||
return messagesByPeerID
|
||||
}
|
||||
|
||||
func unreadDirectPeerIDs() -> Set<PeerID> {
|
||||
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
|
||||
guard case .direct(let handle) = conversationID else { return }
|
||||
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronizeSelection(
|
||||
activeChannel: ChannelID,
|
||||
selectedPeerID: PeerID?,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
setSelectedPeerID(
|
||||
selectedPeerID,
|
||||
activeChannel: activeChannel,
|
||||
identityResolver: identityResolver
|
||||
)
|
||||
}
|
||||
|
||||
func synchronizePrivateChats(
|
||||
_ privateChats: [PeerID: [BitchatMessage]],
|
||||
unreadPeerIDs: Set<PeerID>,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
var liveConversations = Set<ConversationID>()
|
||||
|
||||
for (peerID, messages) in privateChats {
|
||||
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
|
||||
let conversationID = ConversationID.direct(handle)
|
||||
liveConversations.insert(conversationID)
|
||||
directHandlesByConversation[conversationID] = handle
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
}
|
||||
|
||||
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
|
||||
guard case .direct = conversationID else { return false }
|
||||
return !liveConversations.contains(conversationID)
|
||||
}
|
||||
|
||||
for conversationID in staleDirectConversations {
|
||||
messagesByConversation.removeValue(forKey: conversationID)
|
||||
unreadConversations.remove(conversationID)
|
||||
directHandlesByConversation.removeValue(forKey: conversationID)
|
||||
}
|
||||
|
||||
let publicUnread = unreadConversations.filter { conversationID in
|
||||
switch conversationID {
|
||||
case .mesh, .geohash:
|
||||
return true
|
||||
case .direct:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
|
||||
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||
result.insert(.direct(handle))
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(_ conversationID: ConversationID) {
|
||||
unreadConversations.remove(conversationID)
|
||||
}
|
||||
|
||||
func markRead(
|
||||
peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
|
||||
}
|
||||
|
||||
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
|
||||
var uniqueMessages: [String: BitchatMessage] = [:]
|
||||
|
||||
for message in messages {
|
||||
uniqueMessages[message.id] = message
|
||||
}
|
||||
|
||||
return uniqueMessages.values.sorted { lhs, rhs in
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
private func directConversationID(
|
||||
for peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) -> ConversationID {
|
||||
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||
let conversationID = ConversationID.direct(handle)
|
||||
directHandlesByConversation[conversationID] = handle
|
||||
return conversationID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppChromeModel: ObservableObject {
|
||||
@Published private(set) var hasUnreadPrivateMessages = false
|
||||
@Published var nickname: String
|
||||
@Published var showingFingerprintFor: PeerID?
|
||||
@Published var isAppInfoPresented = false
|
||||
@Published var isLocationChannelsSheetPresented = false
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
}
|
||||
|
||||
var shouldSuppressScreenshotNotification: Bool {
|
||||
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
self.nickname = nickname
|
||||
if chatViewModel.nickname != nickname {
|
||||
chatViewModel.nickname = nickname
|
||||
}
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
chatViewModel.validateAndSaveNickname()
|
||||
if nickname != chatViewModel.nickname {
|
||||
nickname = chatViewModel.nickname
|
||||
}
|
||||
}
|
||||
|
||||
func openMostRelevantPrivateChat() {
|
||||
chatViewModel.openMostRelevantPrivateChat()
|
||||
}
|
||||
|
||||
func showFingerprint(for peerID: PeerID) {
|
||||
showingFingerprintFor = peerID
|
||||
}
|
||||
|
||||
func clearFingerprint() {
|
||||
showingFingerprintFor = nil
|
||||
}
|
||||
|
||||
func presentAppInfo() {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||
privateInboxModel.$unreadPeerIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] unreadPeerIDs in
|
||||
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] nickname in
|
||||
guard let self, self.nickname != nickname else { return }
|
||||
self.nickname = nickname
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$showBluetoothAlert
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showBluetoothAlert)
|
||||
|
||||
chatViewModel.$bluetoothAlertMessage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothAlertMessage)
|
||||
|
||||
chatViewModel.$bluetoothState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothState)
|
||||
|
||||
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
import UserNotifications
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppRuntime: ObservableObject {
|
||||
let chatViewModel: ChatViewModel
|
||||
let events = AppEventStream()
|
||||
let conversationStore: ConversationStore
|
||||
let peerIdentityStore: PeerIdentityStore
|
||||
let locationPresenceStore: LocationPresenceStore
|
||||
let publicChatModel: PublicChatModel
|
||||
let privateInboxModel: PrivateInboxModel
|
||||
let privateConversationModel: PrivateConversationModel
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private var lastNostrRelayConnectedState = false
|
||||
private var didHandleInitialNostrConnection = false
|
||||
|
||||
#if os(iOS)
|
||||
private var didHandleInitialActive = false
|
||||
private var didEnterBackground = false
|
||||
#endif
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let identityResolver = IdentityResolver()
|
||||
let conversationStore = ConversationStore()
|
||||
let peerIdentityStore = PeerIdentityStore()
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversationStore = conversationStore
|
||||
self.peerIdentityStore = peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain),
|
||||
conversationStore: conversationStore,
|
||||
identityResolver: identityResolver,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore,
|
||||
locationManager: locationManager
|
||||
)
|
||||
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
||||
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
self.privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.verificationModel = VerificationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
)
|
||||
self.peerListModel = PeerListModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
started = true
|
||||
NotificationDelegate.shared.runtime = self
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
announceInitialTorStatusIfNeeded()
|
||||
|
||||
Task(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||
let npub = await MainActor.run {
|
||||
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||
}
|
||||
await MainActor.run {
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
}
|
||||
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
record(.openedURL(url.absoluteString))
|
||||
|
||||
if url.scheme == "bitchat", url.host == "share" {
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
record(.scenePhaseChanged(.background))
|
||||
TorManager.shared.setAppForeground(false)
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
chatViewModel.endGeohashSampling()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||
|
||||
if didHandleInitialActive && didEnterBackground {
|
||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
}
|
||||
} else {
|
||||
didHandleInitialActive = true
|
||||
}
|
||||
|
||||
didEnterBackground = false
|
||||
|
||||
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||
Task.detached {
|
||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||
await MainActor.run {
|
||||
TorURLSession.shared.rebuild()
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
|
||||
case .inactive:
|
||||
record(.scenePhaseChanged(.inactive))
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func applicationWillTerminate() {
|
||||
record(.terminationRequested)
|
||||
chatViewModel.applicationWillTerminate()
|
||||
}
|
||||
|
||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
record(.notificationOpened(peerID: peerID))
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||
record(.deepLinkOpened(deepLink))
|
||||
openExternalURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
func presentationOptions(
|
||||
forNotificationIdentifier identifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
if conversationStore.selectedPrivatePeerID == peerID {
|
||||
return []
|
||||
}
|
||||
return [.banner, .sound]
|
||||
}
|
||||
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deepLink = userInfo["deeplink"] as? String,
|
||||
let geohash = deepLink.components(separatedBy: "/").last,
|
||||
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
channel.geohash == geohash {
|
||||
return []
|
||||
}
|
||||
|
||||
return [.banner, .sound]
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppRuntime {
|
||||
func bindRuntimeObservers() {
|
||||
NostrRelayManager.shared.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isConnected in
|
||||
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleScreenshotCaptured()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
#endif
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
record(.nostrRelayConnectionChanged(isConnected))
|
||||
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard started, becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
if !chatViewModel.nostrHandlersSetup {
|
||||
chatViewModel.setupNostrMessageHandling()
|
||||
chatViewModel.nostrHandlersSetup = true
|
||||
}
|
||||
|
||||
guard !isInitialConnection else { return }
|
||||
|
||||
chatViewModel.resubscribeCurrentGeohash()
|
||||
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||
}
|
||||
|
||||
func announceInitialTorStatusIfNeeded() {
|
||||
if TorManager.shared.torEnforced &&
|
||||
!chatViewModel.torStatusAnnounced &&
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
if appChromeModel.isLocationChannelsSheetPresented {
|
||||
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if appChromeModel.isAppInfoPresented {
|
||||
return
|
||||
}
|
||||
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
func openExternalURL(_ url: URL) {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func record(_ event: AppEvent) {
|
||||
Task {
|
||||
await events.emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var showAutocomplete = false
|
||||
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let privateConversationModel: PrivateConversationModel
|
||||
private let conversationStore: ConversationStore
|
||||
private var activeChannel: ChannelID
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
conversationStore: ConversationStore
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.privateConversationModel = privateConversationModel
|
||||
self.conversationStore = conversationStore
|
||||
self.activeChannel = conversationStore.activeChannel
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||
chatViewModel.currentColorScheme = colorScheme
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
|
||||
func sendHug(to sender: String) {
|
||||
chatViewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
|
||||
func sendSlap(to sender: String) {
|
||||
chatViewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
|
||||
func block(peerID: PeerID?, displayName: String?) {
|
||||
guard let displayName else { return }
|
||||
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||
chatViewModel.completeNickname(nickname, in: &text)
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||
message.mediaAttachment(for: currentNickname)
|
||||
}
|
||||
|
||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||
}
|
||||
|
||||
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||
}
|
||||
|
||||
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||
return nickname
|
||||
}
|
||||
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processSelectedImage(_ image: UIImage?) {
|
||||
chatViewModel.processThenSendImage(image)
|
||||
}
|
||||
#endif
|
||||
|
||||
func processSelectedImage(from url: URL?) {
|
||||
#if os(macOS)
|
||||
chatViewModel.processThenSendImage(from: url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: URL) {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
chatViewModel.$showAutocomplete
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showAutocomplete)
|
||||
|
||||
chatViewModel.$autocompleteSuggestions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$autocompleteSuggestions)
|
||||
|
||||
chatViewModel.$isBatchingPublic
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
conversationStore.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
return
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
canSendMediaInCurrentContext = true
|
||||
case .location:
|
||||
canSendMediaInCurrentContext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||
@Published private(set) var availableChannels: [GeohashChannel]
|
||||
@Published private(set) var selectedChannel: ChannelID
|
||||
@Published private(set) var teleported: Bool
|
||||
@Published private(set) var bookmarks: [String]
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
self.teleported = manager.teleported
|
||||
self.bookmarks = manager.bookmarks
|
||||
self.bookmarkNames = manager.bookmarkNames
|
||||
self.locationNames = manager.locationNames
|
||||
self.userTorEnabled = network.userTorEnabled
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
var currentBuildingGeohash: String? {
|
||||
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
}
|
||||
|
||||
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
guard case .location(let selected) = selectedChannel else { return false }
|
||||
return selected == channel
|
||||
}
|
||||
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
manager.isBookmarked(geohash)
|
||||
}
|
||||
|
||||
func enableLocationChannels() {
|
||||
manager.enableLocationChannels()
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func enableAndRefresh() {
|
||||
manager.enableLocationChannels()
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func beginLiveRefresh() {
|
||||
manager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
func endLiveRefresh() {
|
||||
manager.endLiveRefresh()
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
manager.select(channel)
|
||||
}
|
||||
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
manager.markTeleported(for: geohash, flag)
|
||||
}
|
||||
|
||||
func toggleBookmark(_ geohash: String) {
|
||||
manager.toggleBookmark(geohash)
|
||||
}
|
||||
|
||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||
}
|
||||
|
||||
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||
locationNames[level]
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
availableChannels.isEmpty else {
|
||||
return
|
||||
}
|
||||
refreshChannels()
|
||||
}
|
||||
|
||||
func openLocationChannel(for geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(normalized.count),
|
||||
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||
if !isRegional && !availableChannels.isEmpty {
|
||||
markTeleported(for: normalized, true)
|
||||
}
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
func teleport(to geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
markTeleported(for: normalized, true)
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
manager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$permissionState)
|
||||
|
||||
manager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$availableChannels)
|
||||
|
||||
manager.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedChannel)
|
||||
|
||||
manager.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$teleported)
|
||||
|
||||
manager.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarks)
|
||||
|
||||
manager.$bookmarkNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarkNames)
|
||||
|
||||
manager.$locationNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$locationNames)
|
||||
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
switch length {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
case 8...12: return .building
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var currentGeohash: String?
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
currentGeohash = geohash?.lowercased()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PeerIdentityStore: ObservableObject {
|
||||
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||
|
||||
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||
private(set) var selectedPrivateChatFingerprint: String?
|
||||
|
||||
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||
|
||||
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID[peerID]
|
||||
}
|
||||
|
||||
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||
}
|
||||
|
||||
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||
}
|
||||
|
||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||
stablePeerIDsByShortID = mappings
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprintsByPeerID[peerID]
|
||||
}
|
||||
|
||||
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||
} else {
|
||||
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||
peerFingerprintsByPeerID = mappings
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateFingerprintMapping(
|
||||
from oldPeerID: PeerID,
|
||||
to newPeerID: PeerID,
|
||||
fallback: String? = nil
|
||||
) -> String? {
|
||||
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||
if selectedPrivateChatFingerprint == nil {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
|
||||
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatusCache[peerID]
|
||||
}
|
||||
|
||||
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||
encryptionStatusCache[peerID] = status
|
||||
}
|
||||
|
||||
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||
if let peerID {
|
||||
encryptionStatusCache.removeValue(forKey: peerID)
|
||||
} else {
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatuses[peerID]
|
||||
}
|
||||
|
||||
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||
if let status {
|
||||
encryptionStatuses[peerID] = status
|
||||
} else {
|
||||
encryptionStatuses.removeValue(forKey: peerID)
|
||||
}
|
||||
invalidateEncryptionCache(for: peerID)
|
||||
}
|
||||
|
||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||
encryptionStatuses = statuses
|
||||
}
|
||||
|
||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||
verifiedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
verifiedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
verifiedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(_ fingerprint: String) -> Bool {
|
||||
verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
encryptionStatuses.removeAll()
|
||||
verifiedFingerprints.removeAll()
|
||||
peerFingerprintsByPeerID.removeAll()
|
||||
selectedPrivateChatFingerprint = nil
|
||||
stablePeerIDsByShortID.removeAll()
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MeshPeerRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let hasUnread: Bool
|
||||
let isBlocked: Bool
|
||||
let isFavorite: Bool
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
let isMutualFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let showsVerifiedBadgeWhenOffline: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let isTeleported: Bool
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@Published private(set) var renderID = ""
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversationStore: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private let locationPresenceStore: LocationPresenceStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversationStore: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversationStore = conversationStore
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||
self.allPeers = chatViewModel.allPeers
|
||||
|
||||
bind()
|
||||
refresh()
|
||||
}
|
||||
|
||||
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||
}
|
||||
|
||||
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||
}
|
||||
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
chatViewModel.geohashParticipantCount(for: geohash)
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
}
|
||||
|
||||
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||
}
|
||||
|
||||
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] peers in
|
||||
self?.allPeers = peers
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationPresenceStore.$teleportedGeo
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let verifiedBadge: Bool
|
||||
if !isMe && !peer.isConnected,
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||
} else {
|
||||
verifiedBadge = false
|
||||
}
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||
isMe: isMe,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isConnected: peer.isConnected,
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||
)
|
||||
}
|
||||
|
||||
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||
guard !row.isMe else { return }
|
||||
if row.isConnected {
|
||||
counts.connected += 1
|
||||
counts.reachable += 1
|
||||
} else if row.isReachable {
|
||||
counts.reachable += 1
|
||||
}
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
return chatViewModel.visibleGeohashPeople().map { person in
|
||||
let isMe = person.id == myHex
|
||||
return GeohashPersonRow(
|
||||
id: person.id,
|
||||
displayName: person.displayName,
|
||||
isMe: isMe,
|
||||
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentGeohashIdentityHex() -> String? {
|
||||
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return identity.publicKeyHex.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PrivateInboxModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
private let conversationStore: ConversationStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversationStore: ConversationStore) {
|
||||
self.conversationStore = conversationStore
|
||||
|
||||
bind()
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
guard let peerID else { return [] }
|
||||
return messagesByPeerID[peerID] ?? []
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] peerID in
|
||||
self?.selectedPeerID = peerID
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$messagesByConversation
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
|
||||
}
|
||||
|
||||
private func refreshMessages() {
|
||||
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
|
||||
var peerIDs = Set(nextMessagesByPeerID.keys)
|
||||
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
|
||||
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
|
||||
peerIDs.insert(selectedPeerID)
|
||||
}
|
||||
|
||||
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
|
||||
nextMessagesByPeerID[peerID] = []
|
||||
}
|
||||
|
||||
messagesByPeerID = nextMessagesByPeerID
|
||||
}
|
||||
}
|
||||
|
||||
enum PrivateConversationAvailability: Equatable {
|
||||
case bluetoothConnected
|
||||
case meshReachable
|
||||
case nostrAvailable
|
||||
case offline
|
||||
}
|
||||
|
||||
struct PrivateConversationHeaderState: Equatable {
|
||||
let conversationPeerID: PeerID
|
||||
let headerPeerID: PeerID
|
||||
let displayName: String
|
||||
let availability: PrivateConversationAvailability
|
||||
let isFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PrivateConversationModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversationStore: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversationStore: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversationStore = conversationStore
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
let initialPeerID = conversationStore.selectedPrivatePeerID
|
||||
self.selectedPeerID = initialPeerID
|
||||
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func openConversation(for peerID: PeerID) {
|
||||
if peerID.isGeoChat {
|
||||
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||
} else {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func endConversation() {
|
||||
chatViewModel.endPrivateChat()
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavoriteForSelectedConversation() {
|
||||
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||
toggleFavorite(peerID: headerPeerID)
|
||||
}
|
||||
|
||||
func markMessagesAsRead(from peerID: PeerID) {
|
||||
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshSelectedConversation() {
|
||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: headerPeerID,
|
||||
displayName: displayName,
|
||||
availability: availability,
|
||||
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||
encryptionStatus: encryptionStatus
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(
|
||||
for conversationPeerID: PeerID,
|
||||
headerPeerID: PeerID,
|
||||
peer: BitchatPeer?
|
||||
) -> String {
|
||||
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||
}
|
||||
if let displayName = peer?.displayName {
|
||||
return displayName
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||
return nickname
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||
), !favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let identity = candidates.first,
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
} else if let noiseKey = headerPeerID.noiseKey {
|
||||
let fingerprint = noiseKey.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
if let connectionState = peer?.connectionState {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return .bluetoothConnected
|
||||
case .meshReachable:
|
||||
return .meshReachable
|
||||
case .nostrAvailable:
|
||||
return .nostrAvailable
|
||||
case .offline:
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
return .meshReachable
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
return .bluetoothConnected
|
||||
}
|
||||
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PublicChatModel: ObservableObject {
|
||||
@Published private(set) var activeChannel: ChannelID
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
|
||||
private let conversationStore: ConversationStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversationStore: ConversationStore) {
|
||||
self.activeChannel = conversationStore.activeChannel
|
||||
self.conversationStore = conversationStore
|
||||
|
||||
bind()
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$messagesByConversation
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshMessages() {
|
||||
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct FingerprintPresentationState: Equatable {
|
||||
let statusPeerID: PeerID
|
||||
let peerNickname: String
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
}
|
||||
}
|
||||
|
||||
enum VerificationScanOutcome: Equatable {
|
||||
case requested(String)
|
||||
case notFound
|
||||
case invalid
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class VerificationModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||
|
||||
bind(privateConversationModel: privateConversationModel)
|
||||
}
|
||||
|
||||
func myQRString() -> String {
|
||||
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
chatViewModel.beginQRVerification(with: qr)
|
||||
}
|
||||
|
||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
return .requested(qr.nickname)
|
||||
}
|
||||
|
||||
func verifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.verifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func unverifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.unverifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func isVerified(peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return peerIdentityStore.isVerified(fingerprint)
|
||||
}
|
||||
|
||||
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
|
||||
return FingerprintPresentationState(
|
||||
statusPeerID: statusPeerID,
|
||||
peerNickname: peerNickname,
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedPeerID)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||
return peer.displayName
|
||||
}
|
||||
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||
return name
|
||||
}
|
||||
if let data = peerID.noiseKey {
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
let fingerprint = data.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
+42
-200
@@ -6,128 +6,55 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||
static let groupID = "group.\(bundleID)"
|
||||
|
||||
@StateObject private var chatViewModel: ChatViewModel
|
||||
|
||||
@StateObject private var runtime: AppRuntime
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
// Skip the very first .active-triggered Tor restart on cold launch
|
||||
@State private var didHandleInitialActive: Bool = false
|
||||
@State private var didEnterBackground: Bool = false
|
||||
#elseif os(macOS)
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
|
||||
init() {
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
wrappedValue: ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
|
||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
// Warm up georelay directory and refresh if stale (once/day)
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(chatViewModel)
|
||||
.environmentObject(runtime.publicChatModel)
|
||||
.environmentObject(runtime.privateInboxModel)
|
||||
.environmentObject(runtime.privateConversationModel)
|
||||
.environmentObject(runtime.verificationModel)
|
||||
.environmentObject(runtime.conversationUIModel)
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.onAppear {
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
let nickname = chatViewModel.nickname
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
|
||||
// Start presence service (will wait for Tor readiness)
|
||||
GeohashPresenceService.shared.start()
|
||||
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
runtime.handleOpenURL(url)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
// Always send Tor to dormant on background for a clean restart later.
|
||||
TorManager.shared.setAppForeground(false)
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
// Stop geohash sampling while backgrounded
|
||||
Task { @MainActor in
|
||||
chatViewModel.endGeohashSampling()
|
||||
}
|
||||
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
||||
NostrRelayManager.shared.disconnect()
|
||||
didEnterBackground = true
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
// On initial cold launch, Tor was just started in onAppear.
|
||||
// Skip the deterministic restart the first time we become active.
|
||||
if didHandleInitialActive && didEnterBackground {
|
||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
}
|
||||
} else {
|
||||
didHandleInitialActive = true
|
||||
}
|
||||
didEnterBackground = false
|
||||
if TorManager.shared.isAutoStartAllowed() {
|
||||
Task.detached {
|
||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||
await MainActor.run {
|
||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||
TorURLSession.shared.rebuild()
|
||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
checkForSharedContent()
|
||||
case .inactive:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
runtime.handleScenePhaseChange(newPhase)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
// Check for shared content when app becomes active
|
||||
checkForSharedContent()
|
||||
runtime.handleDidBecomeActiveNotification()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
// App became active
|
||||
runtime.handleMacDidBecomeActiveNotification()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -136,66 +63,18 @@ struct BitchatApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
if url.scheme == "bitchat" && url.host == "share" {
|
||||
// Handle shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkForSharedContent() {
|
||||
// Check app group for shared content from extension
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only process if shared within configured window
|
||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||
|
||||
// Clear the shared content
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
// No need to force synchronize here
|
||||
|
||||
// Send the shared content immediately on the main queue
|
||||
DispatchQueue.main.async {
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
// Send plain URL
|
||||
self.chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
// Fallback to simple URL
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
} else {
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -204,79 +83,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
import AppKit
|
||||
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationDelegate()
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
// Handle deeplink (e.g., geohash activity)
|
||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||
#else
|
||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
// Access main-actor-isolated property via Task
|
||||
Task { @MainActor in
|
||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
completionHandler([])
|
||||
} else {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let options = await self.runtime?.presentationOptions(
|
||||
forNotificationIdentifier: identifier,
|
||||
userInfo: userInfo
|
||||
) ?? [.banner, .sound]
|
||||
completionHandler(options)
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,30 +15,39 @@ enum ImageUtilsError: Error {
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
// Allow up to 10MB source images (will be scaled down)
|
||||
guard fileSize <= 10 * 1024 * 1024 else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension)
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func validateImageSource(at url: URL) throws {
|
||||
// Security H1: Check file size BEFORE reading into memory.
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int,
|
||||
fileSize > 0,
|
||||
fileSize <= maxSourceImageBytes else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||
CGImageSourceGetType(source) != nil else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
@@ -64,7 +73,7 @@ enum ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@@ -106,7 +115,7 @@ enum ImageUtils {
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
@@ -142,7 +151,7 @@ enum ImageUtils {
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL()
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@@ -186,12 +195,17 @@ enum ImageUtils {
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL() throws -> URL {
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||
|
||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
let directory: URL
|
||||
if let outputDirectory {
|
||||
directory = outputDirectory
|
||||
} else {
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
@@ -202,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||
} catch {
|
||||
// Log error but continue with empty cache
|
||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||
cache = IdentityCache()
|
||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||
SecureLogger.warning(
|
||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum Base64URLCoding {
|
||||
static func encode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func decode(_ string: String) -> Data? {
|
||||
var base64 = string
|
||||
let padding = (4 - (base64.count % 4)) % 4
|
||||
if padding > 0 {
|
||||
base64 += String(repeating: "=", count: padding)
|
||||
}
|
||||
base64 = base64
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
@@ -381,12 +381,7 @@ final class GeoRelayDirectory {
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
host = host.replacingOccurrences(of: "http://", with: "")
|
||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ struct NostrProtocol {
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
private static func decrypt(
|
||||
@@ -314,7 +314,7 @@ struct NostrProtocol {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
@@ -501,6 +501,10 @@ struct NostrEvent: Codable {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
guard Self.isWithinInboundTagLimits(tags) else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
self.created_at = createdAt
|
||||
@@ -509,6 +513,17 @@ struct NostrEvent: Codable {
|
||||
self.content = content
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
private static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||
|
||||
for tag in tags {
|
||||
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else { return false }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
@@ -580,24 +595,9 @@ enum NostrError: Error {
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func base64URLEncode(_ data: Data) -> String {
|
||||
return data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s
|
||||
let pad = (4 - (str.count % 4)) % 4
|
||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
|
||||
@@ -121,16 +121,15 @@ final class NostrRelayManager: ObservableObject {
|
||||
var nextReconnectTime: Date?
|
||||
}
|
||||
|
||||
// Default relay list (can be customized)
|
||||
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||
private static let defaultRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
"wss://offchain.pub"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays)
|
||||
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
@@ -143,10 +142,19 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
// Coalesce duplicate subscribe requests for the same id within a short window
|
||||
// Coalesce duplicate subscribe requests for the same id within a short window.
|
||||
private let subscribeCoalesceInterval: TimeInterval = 1.0
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var pendingTorConnectionURLs = Set<String>()
|
||||
private var awaitingTorForConnections = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private struct SubscriptionRequestState: Equatable {
|
||||
let messageString: String
|
||||
let relayURLs: Set<String>
|
||||
}
|
||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||
|
||||
// Track EOSE per subscription to signal when initial stored events are done
|
||||
private struct EOSETracker {
|
||||
var pendingRelays: Set<String>
|
||||
@@ -154,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
var timer: Timer?
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
@@ -233,25 +242,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
connectToRelays(relays.map(\.url), shouldLog: true)
|
||||
}
|
||||
|
||||
/// Disconnect from all relays
|
||||
@@ -264,6 +255,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
for (_, tracker) in eoseTrackers {
|
||||
tracker.timer?.invalidate()
|
||||
}
|
||||
eoseTrackers.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
@@ -273,22 +272,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.ensureConnections(to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
connectToRelays(targets)
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
@@ -311,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[relayUrl] {
|
||||
if let connection = connectedConnection(for: relayUrl) {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
} else {
|
||||
stillPending.insert(relayUrl)
|
||||
@@ -333,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Flush only for a specific relay
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
||||
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||
item.pendingRelays.remove(target)
|
||||
if item.pendingRelays.isEmpty {
|
||||
@@ -348,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
if let conn = connections[url] {
|
||||
if let conn = connectedConnection(for: url) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||
item.pendingRelays.remove(url)
|
||||
}
|
||||
@@ -361,6 +350,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||
guard let connection = connections[relayUrl],
|
||||
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||
return nil
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||
func subscribe(
|
||||
@@ -372,24 +369,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||
let now = dependencies.now()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
||||
}
|
||||
}
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||
return
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
messageHandlers[id] = handler
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
@@ -405,12 +390,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
||||
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
||||
return
|
||||
}
|
||||
subscriptionRequestState[id] = requestState
|
||||
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
var existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existingSet.insert(url)
|
||||
}
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
@@ -421,20 +412,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else if shouldWaitForTorBeforeConnecting {
|
||||
pendingEOSECallbacks[id] = onEOSE
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
@@ -492,7 +473,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for url in urls {
|
||||
for rawURL in urls {
|
||||
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
|
||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||
if seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
@@ -506,6 +488,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||
subscribeCoalesce.removeValue(forKey: id)
|
||||
subscriptionRequestState.removeValue(forKey: id)
|
||||
pendingEOSECallbacks.removeValue(forKey: id)
|
||||
eoseTrackers[id]?.timer?.invalidate()
|
||||
eoseTrackers.removeValue(forKey: id)
|
||||
for url in Array(pendingSubscriptions.keys) {
|
||||
pendingSubscriptions[url]?.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
let req = NostrRequest.close(id: id)
|
||||
let message = try? encoder.encode(req)
|
||||
@@ -525,6 +514,100 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||
shouldUseTor && !dependencies.torIsReady()
|
||||
}
|
||||
|
||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let targets = allowedRelayList(from: relayUrls).filter {
|
||||
connections[$0] == nil && !isPermanentlyFailed($0)
|
||||
}
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
if shouldWaitForTorBeforeConnecting {
|
||||
queueConnectionsUntilTorReady(targets)
|
||||
return
|
||||
}
|
||||
|
||||
if shouldLog {
|
||||
let route = shouldUseTor ? "via Tor" : "direct"
|
||||
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
|
||||
}
|
||||
|
||||
for url in targets {
|
||||
connectToRelay(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
|
||||
let targets = allowedRelayList(from: relayUrls).filter {
|
||||
connections[$0] == nil && !isPermanentlyFailed($0)
|
||||
}
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
pendingTorConnectionURLs.formUnion(targets)
|
||||
guard !awaitingTorForConnections else { return }
|
||||
|
||||
awaitingTorForConnections = true
|
||||
let generation = connectionGeneration
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self else { return }
|
||||
guard generation == self.connectionGeneration else { return }
|
||||
|
||||
let pending = Array(self.pendingTorConnectionURLs)
|
||||
self.pendingTorConnectionURLs.removeAll()
|
||||
self.awaitingTorForConnections = false
|
||||
|
||||
guard ready else {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
self.connectToRelays(pending, shouldLog: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
|
||||
guard !requestState.relayURLs.isEmpty else { return true }
|
||||
return requestState.relayURLs.allSatisfy { url in
|
||||
pendingSubscriptions[url]?[id] == requestState.messageString ||
|
||||
subscriptions[url]?.contains(id) == true
|
||||
}
|
||||
}
|
||||
|
||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||
eoseTrackers[id]?.timer?.invalidate()
|
||||
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let tracker = self.eoseTrackers[id] {
|
||||
tracker.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
|
||||
private func startPendingEOSETrackingIfNeeded(id: String) {
|
||||
guard eoseTrackers[id] == nil,
|
||||
let callback = pendingEOSECallbacks.removeValue(forKey: id),
|
||||
let requestState = subscriptionRequestState[id]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if requestState.relayURLs.isEmpty {
|
||||
callback()
|
||||
} else {
|
||||
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
@@ -550,12 +633,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||
}
|
||||
if shouldWaitForTorBeforeConnecting {
|
||||
queueConnectionsUntilTorReady([urlString])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -592,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
for (id, messageString) in map {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
startPendingEOSETrackingIfNeeded(id: id)
|
||||
connection.send(.string(messageString)) { error in
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||
@@ -671,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
if isGiftWrap {
|
||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
} else {
|
||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
}
|
||||
}
|
||||
case .notice:
|
||||
@@ -801,7 +881,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
/// Manually retry connection to a specific relay
|
||||
func retryConnection(to relayUrl: String) {
|
||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
||||
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
|
||||
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
|
||||
|
||||
// Reset reconnection attempts
|
||||
relays[index].reconnectAttempts = 0
|
||||
@@ -809,13 +890,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].lastError = nil
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[relayUrl] {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
// Attempt immediate reconnection
|
||||
connectToRelay(relayUrl)
|
||||
connectToRelay(normalizedRelayUrl)
|
||||
}
|
||||
|
||||
/// Get detailed status for all relays
|
||||
@@ -882,6 +963,7 @@ private enum ParsedInbound {
|
||||
|
||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let data = message.data,
|
||||
data.count <= TransportConfig.nostrMaxInboundMessageBytes,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String else {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
enum NostrRelayURL {
|
||||
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return nil }
|
||||
|
||||
if !value.contains("://"), let defaultScheme {
|
||||
value = "\(defaultScheme)://\(value)"
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: value),
|
||||
let rawScheme = components.scheme?.lowercased(),
|
||||
let rawHost = components.host?.lowercased(),
|
||||
!rawHost.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch rawScheme {
|
||||
case "wss", "https":
|
||||
components.scheme = "wss"
|
||||
if components.port == 443 {
|
||||
components.port = nil
|
||||
}
|
||||
case "ws", "http":
|
||||
components.scheme = "ws"
|
||||
if components.port == 80 {
|
||||
components.port = nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
components.host = rawHost
|
||||
if components.path == "/" {
|
||||
components.path = ""
|
||||
}
|
||||
components.fragment = nil
|
||||
|
||||
return components.string
|
||||
}
|
||||
|
||||
static func directoryAddress(_ rawValue: String) -> String? {
|
||||
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||
normalized.removeFirst(prefix.count)
|
||||
break
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
|
||||
@@ -62,40 +62,6 @@ import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
/// Simplified BitChat protocol message types.
|
||||
/// Reduced from 24 types to just 6 essential ones.
|
||||
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||
enum MessageType: UInt8 {
|
||||
// Public messages (unencrypted)
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
case message = 0x02 // Public chat message
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
// Noise encryption
|
||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .requestSync: return "requestSync"
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Types
|
||||
|
||||
/// Types of payloads embedded within noiseEncrypted messages.
|
||||
@@ -132,35 +98,6 @@ enum LazyHandshakeState {
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case read(by: String, at: Date) // Seen by recipient
|
||||
case failed(reason: String)
|
||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .read(let nickname, _):
|
||||
return "Read by \(nickname)"
|
||||
case .failed(let reason):
|
||||
return "Failed: \(reason)"
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return "Delivered to \(reached)/\(total)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEAnnouncePreflightAcceptance {
|
||||
let announcement: AnnouncementPacket
|
||||
let derivedPeerID: PeerID
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightRejection: Equatable {
|
||||
case malformed
|
||||
case senderMismatch(derivedPeerID: PeerID)
|
||||
case selfAnnounce
|
||||
case stale(ageSeconds: Double)
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightDecision {
|
||||
case accept(BLEAnnouncePreflightAcceptance)
|
||||
case reject(BLEAnnouncePreflightRejection)
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightPolicy {
|
||||
static func evaluate(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
localPeerID: PeerID,
|
||||
now: Date
|
||||
) -> BLEAnnouncePreflightDecision {
|
||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||
return .reject(.malformed)
|
||||
}
|
||||
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
guard derivedPeerID == peerID else {
|
||||
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
||||
}
|
||||
|
||||
guard peerID != localPeerID else {
|
||||
return .reject(.selfAnnounce)
|
||||
}
|
||||
|
||||
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
||||
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
)))
|
||||
}
|
||||
|
||||
return .accept(BLEAnnouncePreflightAcceptance(
|
||||
announcement: announcement,
|
||||
derivedPeerID: derivedPeerID
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
case verified
|
||||
case reject(BLEAnnounceTrustRejection)
|
||||
|
||||
var isVerified: Bool {
|
||||
self == .verified
|
||||
}
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustPolicy {
|
||||
static func evaluate(
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
guard signatureValid else {
|
||||
return .reject(.invalidSignature)
|
||||
}
|
||||
|
||||
return .verified
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEAnnounceResponsePlan: Equatable {
|
||||
let shouldNotifyPeerConnected: Bool
|
||||
let shouldScheduleInitialSync: Bool
|
||||
let shouldSendAnnounceBack: Bool
|
||||
let shouldScheduleAfterglow: Bool
|
||||
}
|
||||
|
||||
enum BLEAnnounceResponsePolicy {
|
||||
static func plan(
|
||||
isDirectAnnounce: Bool,
|
||||
isNewPeer: Bool,
|
||||
isReconnectedPeer: Bool,
|
||||
shouldSendAnnounceBack: Bool
|
||||
) -> BLEAnnounceResponsePlan {
|
||||
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
||||
|
||||
return BLEAnnounceResponsePlan(
|
||||
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
||||
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
||||
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
||||
shouldScheduleAfterglow: isNewPeer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
|
||||
init(
|
||||
lastSent: Date = .distantPast,
|
||||
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
|
||||
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
|
||||
) {
|
||||
self.lastSent = lastSent
|
||||
self.normalMinimumInterval = normalMinimumInterval
|
||||
self.forcedMinimumInterval = forcedMinimumInterval
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEConnectionCandidate<Peripheral> {
|
||||
let peripheral: Peripheral
|
||||
let peripheralID: String
|
||||
let rssi: Int
|
||||
let name: String
|
||||
let isConnectable: Bool
|
||||
let discoveredAt: Date
|
||||
}
|
||||
|
||||
struct BLEExistingConnectionState {
|
||||
let isConnecting: Bool
|
||||
let isConnected: Bool
|
||||
let lastConnectionAttempt: Date?
|
||||
}
|
||||
|
||||
enum BLEPeripheralConnectionState {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
enum BLEDiscoveryDecision: Equatable {
|
||||
case ignore
|
||||
case queued
|
||||
case scheduleRetry(after: TimeInterval)
|
||||
case cancelStaleConnection
|
||||
case connectNow
|
||||
}
|
||||
|
||||
enum BLEConnectionQueueDecision<Peripheral> {
|
||||
case none
|
||||
case retryAfter(TimeInterval)
|
||||
case connect(BLEConnectionCandidate<Peripheral>)
|
||||
}
|
||||
|
||||
final class BLEConnectionScheduler<Peripheral> {
|
||||
private let maxCentralLinks: Int
|
||||
private let connectRateLimitInterval: TimeInterval
|
||||
private let candidateCap: Int
|
||||
private let weakLinkCooldownSeconds: TimeInterval
|
||||
private let weakLinkRSSICutoff: Int
|
||||
private let recentTimeoutWindowSeconds: TimeInterval
|
||||
private let recentTimeoutCountThreshold: Int
|
||||
|
||||
private var lastGlobalConnectAttempt: Date = .distantPast
|
||||
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
|
||||
private var failureCounts: [String: Int] = [:]
|
||||
private var recentConnectTimeouts: [String: Date] = [:]
|
||||
private var lastIsolatedAt: Date?
|
||||
|
||||
private let initialDynamicRSSIThreshold: Int
|
||||
private(set) var dynamicRSSIThreshold: Int
|
||||
|
||||
var candidateCount: Int {
|
||||
candidates.count
|
||||
}
|
||||
|
||||
init(
|
||||
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
|
||||
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
|
||||
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
||||
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
||||
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
||||
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
|
||||
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
|
||||
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||
) {
|
||||
self.maxCentralLinks = maxCentralLinks
|
||||
self.connectRateLimitInterval = connectRateLimitInterval
|
||||
self.candidateCap = candidateCap
|
||||
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
||||
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
||||
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
|
||||
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
|
||||
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
}
|
||||
|
||||
func handleDiscovery(
|
||||
_ candidate: BLEConnectionCandidate<Peripheral>,
|
||||
connectedOrConnectingCount: Int,
|
||||
existingState: BLEExistingConnectionState?,
|
||||
peripheralState: BLEPeripheralConnectionState,
|
||||
now: Date
|
||||
) -> BLEDiscoveryDecision {
|
||||
guard candidate.isConnectable else { return .ignore }
|
||||
|
||||
if candidate.rssi <= dynamicRSSIThreshold {
|
||||
enqueue(candidate)
|
||||
return .queued
|
||||
}
|
||||
|
||||
if connectedOrConnectingCount >= maxCentralLinks {
|
||||
enqueue(candidate)
|
||||
return .queued
|
||||
}
|
||||
|
||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||
enqueue(candidate)
|
||||
return .scheduleRetry(after: retryDelay)
|
||||
}
|
||||
|
||||
if let existingState {
|
||||
if existingState.isConnected || existingState.isConnecting {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
if let lastAttempt = existingState.lastConnectionAttempt,
|
||||
now.timeIntervalSince(lastAttempt) < 2.0 {
|
||||
return .ignore
|
||||
}
|
||||
}
|
||||
|
||||
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
||||
now.timeIntervalSince(lastTimeout) < 15 {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
switch peripheralState {
|
||||
case .disconnected:
|
||||
return .connectNow
|
||||
case .connecting, .connected:
|
||||
return .cancelStaleConnection
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
|
||||
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
|
||||
candidates[existingIndex] = candidate
|
||||
} else {
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
candidates.sort {
|
||||
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
|
||||
return $0.discoveredAt < $1.discoveredAt
|
||||
}
|
||||
if candidates.count > candidateCap {
|
||||
candidates.removeLast(candidates.count - candidateCap)
|
||||
}
|
||||
}
|
||||
|
||||
func nextCandidate(
|
||||
connectedOrConnectingCount: Int,
|
||||
isAlreadyConnectingOrConnected: (String) -> Bool,
|
||||
now: Date
|
||||
) -> BLEConnectionQueueDecision<Peripheral> {
|
||||
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
|
||||
|
||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||
return .retryAfter(retryDelay)
|
||||
}
|
||||
|
||||
while !candidates.isEmpty {
|
||||
candidates.sort { score($0, now: now) > score($1, now: now) }
|
||||
let candidate = candidates.removeFirst()
|
||||
guard candidate.isConnectable else { continue }
|
||||
|
||||
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
|
||||
enqueue(candidate)
|
||||
return .retryAfter(delay)
|
||||
}
|
||||
|
||||
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return .connect(candidate)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
func recordConnectionAttempt(at now: Date) {
|
||||
lastGlobalConnectAttempt = now
|
||||
}
|
||||
|
||||
func recordConnectionSuccess(peripheralID: String) {
|
||||
failureCounts[peripheralID] = 0
|
||||
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||
}
|
||||
|
||||
func recordConnectionFailure(peripheralID: String) {
|
||||
failureCounts[peripheralID, default: 0] += 1
|
||||
}
|
||||
|
||||
func recordDisconnectError(peripheralID: String, at now: Date) {
|
||||
recentConnectTimeouts[peripheralID] = now
|
||||
}
|
||||
|
||||
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
||||
recentConnectTimeouts[peripheralID] = now
|
||||
recordConnectionFailure(peripheralID: peripheralID)
|
||||
}
|
||||
|
||||
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lastGlobalConnectAttempt = .distantPast
|
||||
candidates.removeAll()
|
||||
failureCounts.removeAll()
|
||||
recentConnectTimeouts.removeAll()
|
||||
lastIsolatedAt = nil
|
||||
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateRSSIThreshold(
|
||||
connectedCount: Int,
|
||||
connectedOrConnectingLinkCount: Int,
|
||||
now: Date
|
||||
) -> Int {
|
||||
if connectedCount == 0 {
|
||||
if lastIsolatedAt == nil { lastIsolatedAt = now }
|
||||
let isolatedAt = lastIsolatedAt ?? now
|
||||
let elapsed = now.timeIntervalSince(isolatedAt)
|
||||
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
|
||||
? TransportConfig.bleRSSIIsolatedRelaxed
|
||||
: TransportConfig.bleRSSIIsolatedBase
|
||||
return dynamicRSSIThreshold
|
||||
}
|
||||
|
||||
lastIsolatedAt = nil
|
||||
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
|
||||
threshold = TransportConfig.bleRSSIConnectedThreshold
|
||||
}
|
||||
|
||||
let recentTimeouts = recentConnectTimeouts.filter {
|
||||
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
|
||||
}.count
|
||||
if recentTimeouts >= recentTimeoutCountThreshold {
|
||||
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
|
||||
}
|
||||
|
||||
dynamicRSSIThreshold = threshold
|
||||
return threshold
|
||||
}
|
||||
|
||||
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
|
||||
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
|
||||
guard elapsed < connectRateLimitInterval else { return nil }
|
||||
return connectRateLimitInterval - elapsed + 0.05
|
||||
}
|
||||
|
||||
private func weakLinkRetryDelay(
|
||||
for candidate: BLEConnectionCandidate<Peripheral>,
|
||||
now: Date
|
||||
) -> TimeInterval? {
|
||||
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
|
||||
let elapsed = now.timeIntervalSince(lastTimeout)
|
||||
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
|
||||
let remaining = weakLinkCooldownSeconds - elapsed
|
||||
return min(max(2.0, remaining), 15.0)
|
||||
}
|
||||
|
||||
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
|
||||
let failures = failureCounts[candidate.peripheralID] ?? 0
|
||||
let penalty = min(20, 1 << min(4, failures))
|
||||
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
|
||||
now.timeIntervalSince($0) < 60 ? 10 : 0
|
||||
} ?? 0
|
||||
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
|
||||
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
|
||||
return base + recency - penalty - timeoutBias
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEDirectedRelaySpoolEntry {
|
||||
let recipient: PeerID
|
||||
let packet: BitchatPacket
|
||||
}
|
||||
|
||||
struct BLEDirectedRelaySpool {
|
||||
private struct StoredPacket {
|
||||
let packet: BitchatPacket
|
||||
let enqueuedAt: Date
|
||||
}
|
||||
|
||||
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
packetsByRecipient.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
packetsByRecipient.values.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func enqueue(
|
||||
packet: BitchatPacket,
|
||||
recipient: PeerID,
|
||||
messageID: String,
|
||||
enqueuedAt: Date
|
||||
) -> Bool {
|
||||
var packets = packetsByRecipient[recipient] ?? [:]
|
||||
guard packets[messageID] == nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
|
||||
packetsByRecipient[recipient] = packets
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
|
||||
var entries: [BLEDirectedRelaySpoolEntry] = []
|
||||
|
||||
for (recipient, packets) in packetsByRecipient {
|
||||
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
|
||||
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
|
||||
}
|
||||
}
|
||||
|
||||
packetsByRecipient.removeAll()
|
||||
return entries
|
||||
}
|
||||
|
||||
mutating func pruneExpired(now: Date, window: TimeInterval) {
|
||||
guard !packetsByRecipient.isEmpty else { return }
|
||||
|
||||
var pruned: [PeerID: [String: StoredPacket]] = [:]
|
||||
for (recipient, packets) in packetsByRecipient {
|
||||
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
|
||||
if !freshPackets.isEmpty {
|
||||
pruned[recipient] = freshPackets
|
||||
}
|
||||
}
|
||||
packetsByRecipient = pruned
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
packetsByRecipient.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLEFanoutSelection: Equatable {
|
||||
let peripheralIDs: Set<String>
|
||||
let centralIDs: Set<String>
|
||||
}
|
||||
|
||||
enum BLEFanoutSelector {
|
||||
static func selectLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
directedPeerHint: PeerID?,
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let allowed = allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
)
|
||||
|
||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(allowed.peripheralIDs),
|
||||
centralIDs: Set(allowed.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: deterministicSubset(
|
||||
ids: allowed.peripheralIDs,
|
||||
k: subsetSize(for: allowed.peripheralIDs.count),
|
||||
seed: messageID
|
||||
),
|
||||
centralIDs: deterministicSubset(
|
||||
ids: allowed.centralIDs,
|
||||
k: subsetSize(for: allowed.centralIDs.count),
|
||||
seed: messageID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func allowedLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID>
|
||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||
var allowedPeripheralIDs = peripheralIDs
|
||||
var allowedCentralIDs = centralIDs
|
||||
var blockedLinks = excludedLinks
|
||||
|
||||
if let ingressLink {
|
||||
blockedLinks.insert(ingressLink)
|
||||
}
|
||||
|
||||
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
||||
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
||||
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
||||
directedPeerHint == nil
|
||||
&& packetType != MessageType.fragment.rawValue
|
||||
&& packetType != MessageType.announce.rawValue
|
||||
&& packetType != MessageType.requestSync.rawValue
|
||||
}
|
||||
|
||||
private static func subsetSize(for count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
if count <= 2 { return count }
|
||||
|
||||
var value = count - 1
|
||||
var bits = 0
|
||||
while value > 0 {
|
||||
value >>= 1
|
||||
bits += 1
|
||||
}
|
||||
return min(count, max(1, bits + 1))
|
||||
}
|
||||
|
||||
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||
|
||||
var scored: [(score: [UInt8], id: String)] = []
|
||||
for id in ids {
|
||||
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||
let digest = Array(SHA256.hash(data: data))
|
||||
scored.append((digest, id))
|
||||
}
|
||||
|
||||
scored.sort { lhs, rhs in
|
||||
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
||||
if lhs.score[index] != rhs.score[index] {
|
||||
return lhs.score[index] < rhs.score[index]
|
||||
}
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
|
||||
return Set(scored.prefix(k).map(\.id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEFileTransferDeliveryPlan: Equatable {
|
||||
let isPrivateMessage: Bool
|
||||
let shouldTrackForSync: Bool
|
||||
}
|
||||
|
||||
enum BLEFileTransferPolicy {
|
||||
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
|
||||
peerID == localPeerID && packet.ttl != 0
|
||||
}
|
||||
|
||||
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
|
||||
guard let recipientID = packet.recipientID else {
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||
}
|
||||
|
||||
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
|
||||
if isBroadcast {
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||
}
|
||||
|
||||
guard PeerID(hexData: recipientID) == localPeerID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileAcceptance {
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
}
|
||||
|
||||
enum BLEIncomingFileRejection: Error, Equatable {
|
||||
case malformedPayload
|
||||
case payloadTooLarge(bytes: Int)
|
||||
case unsupportedMime(mimeType: String?, bytes: Int)
|
||||
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
||||
return .failure(.unsupportedMime(
|
||||
mimeType: filePacket.mimeType,
|
||||
bytes: filePacket.content.count
|
||||
))
|
||||
}
|
||||
|
||||
guard mime.matches(data: filePacket.content) else {
|
||||
return .failure(.magicMismatch(
|
||||
mime: mime,
|
||||
bytes: filePacket.content.count,
|
||||
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
))
|
||||
}
|
||||
|
||||
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEFragmentKey: Hashable, Equatable {
|
||||
let sender: UInt64
|
||||
let id: UInt64
|
||||
}
|
||||
|
||||
struct BLEFragmentHeader: Equatable {
|
||||
let key: BLEFragmentKey
|
||||
let index: Int
|
||||
let total: Int
|
||||
let originalType: UInt8
|
||||
let fragmentData: Data
|
||||
let isBroadcastFragment: Bool
|
||||
|
||||
var idLogString: String {
|
||||
String(format: "%016llx", key.id)
|
||||
}
|
||||
|
||||
init?(packet: BitchatPacket) {
|
||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
|
||||
guard packet.payload.count >= 13 else { return nil }
|
||||
|
||||
var senderU64: UInt64 = 0
|
||||
for byte in packet.senderID.prefix(8) {
|
||||
senderU64 = (senderU64 << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
var fragmentU64: UInt64 = 0
|
||||
for byte in packet.payload.prefix(8) {
|
||||
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
|
||||
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
|
||||
|
||||
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let isBroadcastFragment: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
|
||||
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
|
||||
self.index = index
|
||||
self.total = total
|
||||
self.originalType = packet.payload[12]
|
||||
self.fragmentData = Data(packet.payload.suffix(from: 13))
|
||||
self.isBroadcastFragment = isBroadcastFragment
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEFragmentAssemblyBuffer {
|
||||
enum AppendResult: Equatable {
|
||||
case stored(header: BLEFragmentHeader, started: Bool)
|
||||
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
|
||||
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
|
||||
}
|
||||
|
||||
private struct Metadata {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
|
||||
|
||||
mutating func removeAll() {
|
||||
fragmentsByKey.removeAll()
|
||||
metadataByKey.removeAll()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeExpired(before cutoff: Date) -> Int {
|
||||
let expiredKeys = metadataByKey
|
||||
.filter { $0.value.timestamp < cutoff }
|
||||
.map(\.key)
|
||||
|
||||
for key in expiredKeys {
|
||||
fragmentsByKey.removeValue(forKey: key)
|
||||
metadataByKey.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
return expiredKeys.count
|
||||
}
|
||||
|
||||
mutating func append(
|
||||
_ header: BLEFragmentHeader,
|
||||
maxInFlightAssemblies: Int,
|
||||
now: Date = Date()
|
||||
) -> AppendResult {
|
||||
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
|
||||
|
||||
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||
let limit = Self.assemblyLimit(for: header.originalType)
|
||||
let projectedSize = currentSize + header.fragmentData.count
|
||||
|
||||
guard projectedSize <= limit else {
|
||||
fragmentsByKey.removeValue(forKey: header.key)
|
||||
metadataByKey.removeValue(forKey: header.key)
|
||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||
|
||||
guard let fragments = fragmentsByKey[header.key],
|
||||
fragments.count == header.total else {
|
||||
return .stored(header: header, started: started)
|
||||
}
|
||||
|
||||
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
|
||||
if let fragment = fragments[index] {
|
||||
data.append(fragment)
|
||||
}
|
||||
}
|
||||
|
||||
fragmentsByKey.removeValue(forKey: header.key)
|
||||
metadataByKey.removeValue(forKey: header.key)
|
||||
|
||||
return .complete(header: header, reassembledData: reassembled, started: started)
|
||||
}
|
||||
|
||||
private mutating func startAssemblyIfNeeded(
|
||||
for header: BLEFragmentHeader,
|
||||
maxInFlightAssemblies: Int,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard fragmentsByKey[header.key] == nil else { return false }
|
||||
|
||||
if fragmentsByKey.count >= maxInFlightAssemblies,
|
||||
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||
fragmentsByKey.removeValue(forKey: oldest)
|
||||
metadataByKey.removeValue(forKey: oldest)
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
|
||||
return FileTransferLimits.maxPayloadBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEInboundWriteChunk: Equatable {
|
||||
let offset: Int
|
||||
let data: Data
|
||||
}
|
||||
|
||||
struct BLEInboundWriteAppendMetadata: Equatable {
|
||||
let accumulatedBytes: Int
|
||||
let appendedBytes: Int
|
||||
let offsets: [Int]
|
||||
let packetType: UInt8?
|
||||
}
|
||||
|
||||
struct BLEInboundWriteBuffer {
|
||||
enum AppendResult {
|
||||
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
|
||||
case waiting(metadata: BLEInboundWriteAppendMetadata)
|
||||
case oversized(metadata: BLEInboundWriteAppendMetadata)
|
||||
}
|
||||
|
||||
private var buffersByCentralID: [String: Data] = [:]
|
||||
|
||||
mutating func removeAll() {
|
||||
buffersByCentralID.removeAll()
|
||||
}
|
||||
|
||||
mutating func append(
|
||||
chunks: [BLEInboundWriteChunk],
|
||||
for centralID: String,
|
||||
capBytes: Int
|
||||
) -> AppendResult {
|
||||
var combined = buffersByCentralID[centralID] ?? Data()
|
||||
var appendedBytes = 0
|
||||
var offsets: [Int] = []
|
||||
|
||||
for chunk in chunks where !chunk.data.isEmpty {
|
||||
offsets.append(chunk.offset)
|
||||
let end = chunk.offset + chunk.data.count
|
||||
|
||||
if combined.count < end {
|
||||
combined.append(Data(repeating: 0, count: end - combined.count))
|
||||
}
|
||||
|
||||
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
|
||||
appendedBytes += chunk.data.count
|
||||
}
|
||||
|
||||
let metadata = BLEInboundWriteAppendMetadata(
|
||||
accumulatedBytes: combined.count,
|
||||
appendedBytes: appendedBytes,
|
||||
offsets: offsets,
|
||||
packetType: combined.count >= 2 ? combined[1] : nil
|
||||
)
|
||||
|
||||
if let packet = BinaryProtocol.decode(combined) {
|
||||
buffersByCentralID.removeValue(forKey: centralID)
|
||||
return .decoded(packet: packet, metadata: metadata)
|
||||
}
|
||||
|
||||
guard combined.count <= capBytes else {
|
||||
buffersByCentralID.removeValue(forKey: centralID)
|
||||
return .oversized(metadata: metadata)
|
||||
}
|
||||
|
||||
buffersByCentralID[centralID] = combined
|
||||
return .waiting(metadata: metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
func save(
|
||||
data: Data,
|
||||
preferredName: String?,
|
||||
subdirectory: String,
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
let sanitized = sanitizedFileName(
|
||||
preferredName,
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
fallbackExtension: fallbackExtension
|
||||
)
|
||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
return destination
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||
]
|
||||
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||
|
||||
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { continue }
|
||||
|
||||
for fileURL in contents {
|
||||
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
||||
let size = attrs.fileSize,
|
||||
let modified = attrs.contentModificationDate else { continue }
|
||||
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
||||
}
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
let needToFree = currentUsage - targetUsage
|
||||
var freedSpace: Int64 = 0
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
if freedSpace > 0 {
|
||||
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
var candidate = (name ?? "")
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
.precomposedStringWithCanonicalMapping
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "\\", with: "_")
|
||||
|
||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
||||
if candidate.isEmpty { candidate = defaultName }
|
||||
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
||||
|
||||
if candidate.count > 120 {
|
||||
let ext = (candidate as NSString).pathExtension
|
||||
let base = (candidate as NSString).deletingPathExtension
|
||||
candidate = ext.isEmpty
|
||||
? String(candidate.prefix(120))
|
||||
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
||||
}
|
||||
|
||||
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
||||
candidate += ".\(fallbackExtension)"
|
||||
}
|
||||
|
||||
return candidate.isEmpty ? defaultName : candidate
|
||||
}
|
||||
|
||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||
let directoryPath = directory.standardizedFileURL.path
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||
}
|
||||
|
||||
var candidate = directory.appendingPathComponent(fileName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
for counter in 1..<100 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
candidate = directory.appendingPathComponent(newName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
||||
}
|
||||
|
||||
private static func timestampString(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEIngressLinkID: Hashable, Equatable {
|
||||
case peripheral(String)
|
||||
case central(String)
|
||||
}
|
||||
|
||||
struct BLEIngressPacketContext: Equatable {
|
||||
let receivedFromPeerID: PeerID
|
||||
let validationPeerID: PeerID
|
||||
}
|
||||
|
||||
struct BLEIngressLinkRecord: Equatable {
|
||||
let link: BLEIngressLinkID
|
||||
let peerID: PeerID
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum BLEIngressRejection: Error, Equatable {
|
||||
case selfLoopback(packetType: UInt8)
|
||||
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||
}
|
||||
|
||||
struct BLEIngressLinkRegistry {
|
||||
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
ingressByMessageID.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
ingressByMessageID.removeAll()
|
||||
}
|
||||
|
||||
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
||||
ingressByMessageID[Self.messageID(for: packet)]
|
||||
}
|
||||
|
||||
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
||||
record(for: packet)?.link
|
||||
}
|
||||
|
||||
func peerID(for packet: BitchatPacket) -> PeerID? {
|
||||
record(for: packet)?.peerID
|
||||
}
|
||||
|
||||
mutating func recordIfNew(
|
||||
_ packet: BitchatPacket,
|
||||
link: BLEIngressLinkID,
|
||||
peerID: PeerID,
|
||||
now: Date = Date(),
|
||||
lifetime: TimeInterval
|
||||
) -> Bool {
|
||||
let messageID = Self.messageID(for: packet)
|
||||
if let existing = ingressByMessageID[messageID],
|
||||
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
||||
return false
|
||||
}
|
||||
|
||||
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func prune(before cutoff: Date) {
|
||||
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||
}
|
||||
|
||||
static func packetContext(
|
||||
for packet: BitchatPacket,
|
||||
claimedSenderID: PeerID,
|
||||
boundPeerID: PeerID?,
|
||||
localPeerID: PeerID,
|
||||
directAnnounceTTL: UInt8
|
||||
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
||||
if claimedSenderID == localPeerID,
|
||||
!isSelfAuthoredSyncResponse(packet) {
|
||||
return .failure(.selfLoopback(packetType: packet.type))
|
||||
}
|
||||
|
||||
if let boundPeerID,
|
||||
boundPeerID != claimedSenderID,
|
||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
|
||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
||||
return .success(BLEIngressPacketContext(
|
||||
receivedFromPeerID: receivedFromPeerID,
|
||||
validationPeerID: validationPeerID
|
||||
))
|
||||
}
|
||||
|
||||
static func messageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
packet.isRSR && packet.ttl == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEIngressPacketGuard {
|
||||
enum Rejection: Error, Equatable {
|
||||
case selfLoopback(packetType: UInt8)
|
||||
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||
case invalidRSR(peerID: PeerID)
|
||||
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
|
||||
}
|
||||
|
||||
static func evaluate(
|
||||
packet: BitchatPacket,
|
||||
claimedSenderID: PeerID,
|
||||
boundPeerID: PeerID?,
|
||||
localPeerID: PeerID,
|
||||
directAnnounceTTL: UInt8,
|
||||
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
maxTimestampSkewMs: UInt64 = 120_000,
|
||||
isValidSyncResponse: (PeerID) -> Bool
|
||||
) -> Result<BLEIngressPacketContext, Rejection> {
|
||||
let contextResult = BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: claimedSenderID,
|
||||
boundPeerID: boundPeerID,
|
||||
localPeerID: localPeerID,
|
||||
directAnnounceTTL: directAnnounceTTL
|
||||
)
|
||||
|
||||
let context: BLEIngressPacketContext
|
||||
switch contextResult {
|
||||
case .success(let acceptedContext):
|
||||
context = acceptedContext
|
||||
case .failure(.selfLoopback(let packetType)):
|
||||
return .failure(.selfLoopback(packetType: packetType))
|
||||
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
|
||||
switch validatePayload(
|
||||
packet,
|
||||
from: context.validationPeerID,
|
||||
nowMs: nowMs,
|
||||
maxTimestampSkewMs: maxTimestampSkewMs,
|
||||
isValidSyncResponse: isValidSyncResponse
|
||||
) {
|
||||
case .success:
|
||||
return .success(context)
|
||||
case .failure(let rejection):
|
||||
return .failure(rejection)
|
||||
}
|
||||
}
|
||||
|
||||
static func validatePayload(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
maxTimestampSkewMs: UInt64 = 120_000,
|
||||
isValidSyncResponse: (PeerID) -> Bool
|
||||
) -> Result<Void, Rejection> {
|
||||
if packet.isRSR {
|
||||
guard isValidSyncResponse(peerID) else {
|
||||
return .failure(.invalidRSR(peerID: peerID))
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
let packetTime = packet.timestamp
|
||||
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
|
||||
guard skew <= maxTimestampSkewMs else {
|
||||
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
|
||||
}
|
||||
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import BitFoundation
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
struct BLEPeripheralLinkState {
|
||||
let peripheral: CBPeripheral
|
||||
var characteristic: CBCharacteristic?
|
||||
var peerID: PeerID?
|
||||
var isConnecting: Bool
|
||||
var isConnected: Bool
|
||||
var lastConnectionAttempt: Date?
|
||||
var assembler: NotificationStreamAssembler
|
||||
}
|
||||
|
||||
struct BLEDirectLinkState: Equatable {
|
||||
let hasPeripheral: Bool
|
||||
let hasCentral: Bool
|
||||
}
|
||||
|
||||
struct BLESubscribedCentralSnapshot {
|
||||
let centrals: [CBCentral]
|
||||
let peerIDsByCentralUUID: [String: PeerID]
|
||||
|
||||
func central(for peerID: PeerID) -> CBCentral? {
|
||||
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
|
||||
}
|
||||
}
|
||||
|
||||
final class BLELinkStateStore {
|
||||
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
||||
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
||||
private(set) var subscribedCentrals: [CBCentral] = []
|
||||
private(set) var centralToPeerID: [String: PeerID] = [:]
|
||||
|
||||
var peripheralStates: [BLEPeripheralLinkState] {
|
||||
Array(peripherals.values)
|
||||
}
|
||||
|
||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||
BLESubscribedCentralSnapshot(
|
||||
centrals: subscribedCentrals,
|
||||
peerIDsByCentralUUID: centralToPeerID
|
||||
)
|
||||
}
|
||||
|
||||
var subscribedCentralCount: Int {
|
||||
subscribedCentrals.count
|
||||
}
|
||||
|
||||
var connectedOrConnectingPeripheralCount: Int {
|
||||
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
}
|
||||
|
||||
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
||||
peripherals[peripheralID]
|
||||
}
|
||||
|
||||
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
||||
peripherals[peripheralID] = state
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updatePeripheral(
|
||||
_ peripheralID: String,
|
||||
_ update: (inout BLEPeripheralLinkState) -> Void
|
||||
) -> BLEPeripheralLinkState? {
|
||||
guard var state = peripherals[peripheralID] else { return nil }
|
||||
update(&state)
|
||||
peripherals[peripheralID] = state
|
||||
return state
|
||||
}
|
||||
|
||||
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
|
||||
setPeripheralState(
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: date,
|
||||
assembler: NotificationStreamAssembler()
|
||||
),
|
||||
for: peripheral.identifier.uuidString
|
||||
)
|
||||
}
|
||||
|
||||
func markConnected(_ peripheral: CBPeripheral) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
if updatePeripheral(peripheralID, {
|
||||
$0.isConnecting = false
|
||||
$0.isConnected = true
|
||||
}) == nil {
|
||||
setPeripheralState(
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
),
|
||||
for: peripheralID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
|
||||
updatePeripheral(peripheralID) {
|
||||
$0.characteristic = characteristic
|
||||
}
|
||||
}
|
||||
|
||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
}
|
||||
|
||||
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
||||
}
|
||||
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
peripherals[peripheralID]?.peerID
|
||||
}
|
||||
|
||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
centralToPeerID[centralUUID]
|
||||
}
|
||||
|
||||
func addSubscribedCentral(_ central: CBCentral) {
|
||||
guard !subscribedCentrals.contains(central) else { return }
|
||||
subscribedCentrals.append(central)
|
||||
}
|
||||
|
||||
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
||||
let centralUUID = central.identifier.uuidString
|
||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||
return centralToPeerID.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
centralToPeerID[centralUUID] = peerID
|
||||
}
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||
if let peerID {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
func clearPeripherals() -> [PeerID] {
|
||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearCentrals() -> [PeerID] {
|
||||
let peerIDs = Array(centralToPeerID.values)
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
final class BLELogRateLimiter {
|
||||
private let defaultMinimumInterval: TimeInterval
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
|
||||
private var lastLogTimeByKey: [String: Date] = [:]
|
||||
|
||||
init(defaultMinimumInterval: TimeInterval) {
|
||||
self.defaultMinimumInterval = defaultMinimumInterval
|
||||
}
|
||||
|
||||
func shouldLog(
|
||||
key: String,
|
||||
now: Date = Date(),
|
||||
minimumInterval: TimeInterval? = nil
|
||||
) -> Bool {
|
||||
queue.sync {
|
||||
let interval = minimumInterval ?? defaultMinimumInterval
|
||||
if let lastLogTime = lastLogTimeByKey[key],
|
||||
now.timeIntervalSince(lastLogTime) < interval {
|
||||
return false
|
||||
}
|
||||
lastLogTimeByKey[key] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
queue.sync {
|
||||
lastLogTimeByKey.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEMaintenancePlan: Equatable {
|
||||
let shouldSendAnnounce: Bool
|
||||
let shouldEnsureAdvertising: Bool
|
||||
let shouldRunCleanup: Bool
|
||||
let shouldFlushDirectedSpool: Bool
|
||||
let shouldResetCounter: Bool
|
||||
}
|
||||
|
||||
enum BLEMaintenancePolicy {
|
||||
static func plan(
|
||||
cycle: Int,
|
||||
connectedCount: Int,
|
||||
peerRegistryIsEmpty: Bool,
|
||||
elapsedSinceLastAnnounce: TimeInterval,
|
||||
hasRecentTraffic: Bool,
|
||||
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||
) -> BLEMaintenancePlan {
|
||||
BLEMaintenancePlan(
|
||||
shouldSendAnnounce: shouldSendAnnounce(
|
||||
connectedCount: connectedCount,
|
||||
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
|
||||
hasRecentTraffic: hasRecentTraffic,
|
||||
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
),
|
||||
shouldEnsureAdvertising: peerRegistryIsEmpty,
|
||||
shouldRunCleanup: cycle.isMultiple(of: 3),
|
||||
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
|
||||
shouldResetCounter: cycle >= 6
|
||||
)
|
||||
}
|
||||
|
||||
static func shouldSendAnnounce(
|
||||
connectedCount: Int,
|
||||
elapsedSinceLastAnnounce: TimeInterval,
|
||||
hasRecentTraffic: Bool,
|
||||
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||
) -> Bool {
|
||||
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
|
||||
return true
|
||||
}
|
||||
|
||||
guard connectedCount > 0 else {
|
||||
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
|
||||
}
|
||||
|
||||
let highDegree = connectedCount >= highDegreeThreshold
|
||||
let base = highDegree ?
|
||||
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
|
||||
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
|
||||
let jitter = highDegree ?
|
||||
TransportConfig.bleConnectedAnnounceJitterDense :
|
||||
TransportConfig.bleConnectedAnnounceJitterSparse
|
||||
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
|
||||
|
||||
return elapsedSinceLastAnnounce >= base + jitterOffset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
enum BLENoisePayloadFactory {
|
||||
static func privateMessage(content: String, messageID: String) -> Data? {
|
||||
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return typedPayload(.privateMessage, payload: payload)
|
||||
}
|
||||
|
||||
static func readReceipt(originalMessageID: String) -> Data {
|
||||
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
||||
}
|
||||
|
||||
static func delivered(messageID: String) -> Data {
|
||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||
}
|
||||
|
||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||
var typed = Data([type.rawValue])
|
||||
typed.append(payload)
|
||||
return typed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEPendingPrivateMessage: Equatable {
|
||||
let content: String
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct BLENoiseSessionQueues {
|
||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
privateMessagesByPeerID.removeAll()
|
||||
typedPayloadsByPeerID.removeAll()
|
||||
}
|
||||
|
||||
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
||||
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
||||
}
|
||||
|
||||
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
||||
let messages = privateMessagesByPeerID[peerID] ?? []
|
||||
privateMessagesByPeerID.removeValue(forKey: peerID)
|
||||
return messages
|
||||
}
|
||||
|
||||
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
||||
guard !messages.isEmpty else { return }
|
||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||
}
|
||||
|
||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||
}
|
||||
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||
return payloads
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEOutboundFragmentPlan {
|
||||
let fragmentPackets: [BitchatPacket]
|
||||
let fragmentVersion: UInt8
|
||||
let chunkSize: Int
|
||||
let spacingMs: Int
|
||||
|
||||
var totalFragments: Int {
|
||||
fragmentPackets.count
|
||||
}
|
||||
|
||||
var shouldPauseScanning: Bool {
|
||||
totalFragments > 4
|
||||
}
|
||||
}
|
||||
|
||||
enum BLEOutboundFragmentPlanner {
|
||||
private static let minimumChunkSize = 64
|
||||
private static let fragmentIDLength = 8
|
||||
|
||||
static func makePlan(
|
||||
for request: BLEOutboundFragmentTransferRequest,
|
||||
defaultChunkSize: Int,
|
||||
bleMaxMTU: Int,
|
||||
fragmentID: Data = randomFragmentID()
|
||||
) -> BLEOutboundFragmentPlan? {
|
||||
guard fragmentID.count == fragmentIDLength,
|
||||
let fullData = request.packet.toBinaryData(padding: request.pad) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sizing = sizingPolicy(
|
||||
for: request.packet,
|
||||
requestedMaxChunk: request.maxChunk,
|
||||
defaultChunkSize: defaultChunkSize,
|
||||
bleMaxMTU: bleMaxMTU
|
||||
)
|
||||
|
||||
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
|
||||
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
|
||||
}
|
||||
|
||||
guard !chunks.isEmpty else { return nil }
|
||||
|
||||
let fragmentRecipient: Data? = {
|
||||
if let directedPeer = request.directedPeer {
|
||||
return Data(hexString: directedPeer.id)
|
||||
}
|
||||
return request.packet.recipientID
|
||||
}()
|
||||
|
||||
let fragmentPackets = chunks.enumerated().map { index, chunk in
|
||||
makeFragmentPacket(
|
||||
original: request.packet,
|
||||
fragmentID: fragmentID,
|
||||
index: index,
|
||||
total: chunks.count,
|
||||
fragmentData: chunk,
|
||||
fragmentRecipient: fragmentRecipient,
|
||||
fragmentVersion: sizing.fragmentVersion
|
||||
)
|
||||
}
|
||||
|
||||
return BLEOutboundFragmentPlan(
|
||||
fragmentPackets: fragmentPackets,
|
||||
fragmentVersion: sizing.fragmentVersion,
|
||||
chunkSize: sizing.chunkSize,
|
||||
spacingMs: spacingMs(for: request)
|
||||
)
|
||||
}
|
||||
|
||||
private static func sizingPolicy(
|
||||
for packet: BitchatPacket,
|
||||
requestedMaxChunk: Int?,
|
||||
defaultChunkSize: Int,
|
||||
bleMaxMTU: Int
|
||||
) -> (fragmentVersion: UInt8, chunkSize: Int) {
|
||||
var fragmentVersion: UInt8 = 1
|
||||
var calculatedChunk = defaultChunkSize
|
||||
|
||||
if let route = packet.route, !route.isEmpty {
|
||||
fragmentVersion = 2
|
||||
let routeSize = 1 + (route.count * 8)
|
||||
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
|
||||
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
|
||||
}
|
||||
|
||||
return (
|
||||
fragmentVersion: fragmentVersion,
|
||||
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeFragmentPacket(
|
||||
original packet: BitchatPacket,
|
||||
fragmentID: Data,
|
||||
index: Int,
|
||||
total: Int,
|
||||
fragmentData: Data,
|
||||
fragmentRecipient: Data?,
|
||||
fragmentVersion: UInt8
|
||||
) -> BitchatPacket {
|
||||
var payload = Data()
|
||||
payload.append(fragmentID)
|
||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||
payload.append(packet.type)
|
||||
payload.append(fragmentData)
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: packet.senderID,
|
||||
recipientID: fragmentRecipient,
|
||||
timestamp: packet.timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: packet.ttl,
|
||||
version: fragmentVersion,
|
||||
route: packet.route,
|
||||
isRSR: packet.isRSR
|
||||
)
|
||||
}
|
||||
|
||||
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
|
||||
if request.directedPeer != nil || request.packet.recipientID != nil {
|
||||
return TransportConfig.bleFragmentSpacingDirectedMs
|
||||
}
|
||||
|
||||
return TransportConfig.bleFragmentSpacingMs
|
||||
}
|
||||
|
||||
private static func randomFragmentID() -> Data {
|
||||
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEOutboundFragmentTransferRequest {
|
||||
let packet: BitchatPacket
|
||||
let pad: Bool
|
||||
let maxChunk: Int?
|
||||
let directedPeer: PeerID?
|
||||
let transferId: String?
|
||||
|
||||
var resolvedTransferId: String? {
|
||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||
return transferId ?? packet.payload.sha256Hex()
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEOutboundFragmentTransferScheduler {
|
||||
enum QueuePosition {
|
||||
case front
|
||||
case back
|
||||
}
|
||||
|
||||
enum SubmitResult {
|
||||
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||
}
|
||||
|
||||
enum CancelResult {
|
||||
case active(transferId: String, workItems: [DispatchWorkItem])
|
||||
case pending(transferId: String)
|
||||
case missing
|
||||
}
|
||||
|
||||
enum SentResult: Equatable {
|
||||
case progress(sentFragments: Int, totalFragments: Int)
|
||||
case complete(sentFragments: Int, totalFragments: Int)
|
||||
case missing
|
||||
}
|
||||
|
||||
private struct ActiveTransferState {
|
||||
let totalFragments: Int
|
||||
var sentFragments: Int
|
||||
var workItems: [DispatchWorkItem]
|
||||
}
|
||||
|
||||
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||
|
||||
var activeCount: Int {
|
||||
activeTransfers.count
|
||||
}
|
||||
|
||||
var pendingCount: Int {
|
||||
pendingTransfers.count
|
||||
}
|
||||
|
||||
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
||||
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||
activeTransfers.removeAll()
|
||||
pendingTransfers.removeAll()
|
||||
return active
|
||||
}
|
||||
|
||||
mutating func submit(
|
||||
_ request: BLEOutboundFragmentTransferRequest,
|
||||
maxConcurrentTransfers: Int
|
||||
) -> SubmitResult {
|
||||
guard let transferId = request.resolvedTransferId else {
|
||||
return .start(request: request, reservedTransferId: nil)
|
||||
}
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.append(request)
|
||||
return .queued(request: request, transferId: transferId, position: .back)
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
return .queued(request: request, transferId: transferId, position: .front)
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||
return .start(request: request, reservedTransferId: transferId)
|
||||
}
|
||||
|
||||
mutating func activateReservedTransfer(
|
||||
id transferId: String,
|
||||
totalFragments: Int,
|
||||
workItems: [DispatchWorkItem]
|
||||
) -> Bool {
|
||||
guard activeTransfers[transferId] != nil else { return false }
|
||||
activeTransfers[transferId] = ActiveTransferState(
|
||||
totalFragments: totalFragments,
|
||||
sentFragments: 0,
|
||||
workItems: workItems
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
||||
guard var state = activeTransfers[transferId] else { return false }
|
||||
state.workItems = workItems
|
||||
activeTransfers[transferId] = state
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
||||
activeTransfers.removeValue(forKey: transferId)?.workItems
|
||||
}
|
||||
|
||||
func isActive(_ transferId: String) -> Bool {
|
||||
activeTransfers[transferId] != nil
|
||||
}
|
||||
|
||||
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
||||
if let active = activeTransfers.removeValue(forKey: transferId) {
|
||||
return .active(transferId: transferId, workItems: active.workItems)
|
||||
}
|
||||
|
||||
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
||||
pendingTransfers.remove(at: pendingIndex)
|
||||
return .pending(transferId: transferId)
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
|
||||
mutating func markFragmentSent(transferId: String) -> SentResult {
|
||||
guard var state = activeTransfers[transferId] else { return .missing }
|
||||
|
||||
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
||||
let isComplete = state.sentFragments >= state.totalFragments
|
||||
|
||||
if isComplete {
|
||||
activeTransfers.removeValue(forKey: transferId)
|
||||
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = state
|
||||
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||
}
|
||||
|
||||
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
||||
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
||||
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
||||
|
||||
var results: [SubmitResult] = []
|
||||
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
||||
|
||||
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||
let request = pendingTransfers.removeFirst()
|
||||
availableSlots -= 1
|
||||
|
||||
guard let transferId = request.resolvedTransferId else {
|
||||
results.append(.start(request: request, reservedTransferId: nil))
|
||||
continue
|
||||
}
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
break
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
blockedFront.append(request)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
continue
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||
results.append(.start(request: request, reservedTransferId: transferId))
|
||||
}
|
||||
|
||||
if !blockedFront.isEmpty {
|
||||
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEOutboundLinkPlan: Equatable {
|
||||
let directedPeerHint: PeerID?
|
||||
let fragmentChunkSize: Int?
|
||||
let selectedLinks: BLEFanoutSelection
|
||||
let shouldSpoolDirectedPacket: Bool
|
||||
}
|
||||
|
||||
enum BLEOutboundLinkPlanner {
|
||||
static func plan(
|
||||
packet: BitchatPacket,
|
||||
dataCount: Int,
|
||||
peripheralIDs: [String],
|
||||
peripheralWriteLimits: [Int],
|
||||
centralIDs: [String],
|
||||
centralNotifyLimits: [Int],
|
||||
ingressRecord: BLEIngressLinkRecord?,
|
||||
excludedLinks: Set<BLEIngressLinkID>,
|
||||
directedOnlyPeer: PeerID?
|
||||
) -> BLEOutboundLinkPlan {
|
||||
if let minLimit = minimumLinkLimit(
|
||||
peripheralWriteLimits: peripheralWriteLimits,
|
||||
centralNotifyLimits: centralNotifyLimits
|
||||
), packet.type != MessageType.fragment.rawValue,
|
||||
dataCount > minLimit {
|
||||
return BLEOutboundLinkPlan(
|
||||
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
|
||||
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
||||
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
|
||||
shouldSpoolDirectedPacket: false
|
||||
)
|
||||
}
|
||||
|
||||
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
||||
let selectedLinks = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressRecord?.link,
|
||||
excludedLinks: excludedLinks,
|
||||
directedPeerHint: directedPeerHint,
|
||||
packetType: packet.type,
|
||||
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
||||
)
|
||||
|
||||
return BLEOutboundLinkPlan(
|
||||
directedPeerHint: directedPeerHint,
|
||||
fragmentChunkSize: nil,
|
||||
selectedLinks: selectedLinks,
|
||||
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
|
||||
directedPeerHint: directedPeerHint,
|
||||
selectedLinks: selectedLinks,
|
||||
packetType: packet.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
|
||||
if let explicitPeer { return explicitPeer }
|
||||
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
|
||||
return recipient
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
|
||||
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
|
||||
.compactMap { $0 }
|
||||
.min()
|
||||
}
|
||||
|
||||
static func shouldSpoolDirectedPacket(
|
||||
directedPeerHint: PeerID?,
|
||||
selectedLinks: BLEFanoutSelection,
|
||||
packetType: UInt8
|
||||
) -> Bool {
|
||||
guard directedPeerHint != nil,
|
||||
selectedLinks.peripheralIDs.isEmpty,
|
||||
selectedLinks.centralIDs.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return packetType == MessageType.noiseEncrypted.rawValue ||
|
||||
packetType == MessageType.noiseHandshake.rawValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEPendingNotification<Target> {
|
||||
let data: Data
|
||||
let targets: [Target]?
|
||||
}
|
||||
|
||||
struct BLEOutboundNotificationBuffer<Target> {
|
||||
enum EnqueueResult {
|
||||
case enqueued(count: Int)
|
||||
case full(count: Int)
|
||||
}
|
||||
|
||||
private var notifications: [BLEPendingNotification<Target>] = []
|
||||
|
||||
var count: Int {
|
||||
notifications.count
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
notifications.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
notifications.removeAll()
|
||||
}
|
||||
|
||||
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
|
||||
guard notifications.count < capCount else {
|
||||
return .full(count: notifications.count)
|
||||
}
|
||||
|
||||
notifications.append(BLEPendingNotification(data: data, targets: targets))
|
||||
return .enqueued(count: notifications.count)
|
||||
}
|
||||
|
||||
mutating func takeAll() -> [BLEPendingNotification<Target>] {
|
||||
let pending = notifications
|
||||
notifications.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
|
||||
guard !pending.isEmpty else { return }
|
||||
notifications.insert(contentsOf: pending, at: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEOutboundPacketPolicy {
|
||||
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
||||
|
||||
static func messageID(for packet: BitchatPacket) -> String {
|
||||
BLEIngressLinkRegistry.messageID(for: packet)
|
||||
}
|
||||
|
||||
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||
switch messageType {
|
||||
case .fragment:
|
||||
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||
case .fileTransfer:
|
||||
return .fileTransfer
|
||||
default:
|
||||
return .high
|
||||
}
|
||||
}
|
||||
|
||||
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
||||
max(64, limit - fragmentFrameOverhead)
|
||||
}
|
||||
|
||||
private static func fragmentTotalCount(from payload: Data) -> Int {
|
||||
guard payload.count >= 12 else { return Int(UInt16.max) }
|
||||
let totalHigh = Int(payload[10])
|
||||
let totalLow = Int(payload[11])
|
||||
let total = (totalHigh << 8) | totalLow
|
||||
return max(total, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEOutboundWritePriority: Comparable {
|
||||
let level: Int
|
||||
let suborder: Int
|
||||
|
||||
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
|
||||
|
||||
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
|
||||
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
|
||||
}
|
||||
|
||||
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
|
||||
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
|
||||
|
||||
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
|
||||
if lhs.level != rhs.level { return lhs.level < rhs.level }
|
||||
return lhs.suborder < rhs.suborder
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEPendingWrite {
|
||||
let priority: BLEOutboundWritePriority
|
||||
let data: Data
|
||||
}
|
||||
|
||||
struct BLEOutboundWriteBuffer {
|
||||
enum EnqueueResult {
|
||||
case enqueued(trimmedBytes: Int, remainingBytes: Int)
|
||||
case oversized(bytes: Int)
|
||||
}
|
||||
|
||||
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
|
||||
|
||||
var peripheralIDs: [String] {
|
||||
Array(writesByPeripheralID.keys)
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
writesByPeripheralID.removeAll()
|
||||
}
|
||||
|
||||
mutating func enqueue(
|
||||
data: Data,
|
||||
for peripheralID: String,
|
||||
priority: BLEOutboundWritePriority,
|
||||
capBytes: Int
|
||||
) -> EnqueueResult {
|
||||
guard data.count <= capBytes else {
|
||||
return .oversized(bytes: data.count)
|
||||
}
|
||||
|
||||
var queue = writesByPeripheralID[peripheralID] ?? []
|
||||
let item = BLEPendingWrite(priority: priority, data: data)
|
||||
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
|
||||
queue.insert(item, at: insertIndex)
|
||||
|
||||
var total = queue.reduce(0) { $0 + $1.data.count }
|
||||
var trimmedBytes = 0
|
||||
|
||||
while total > capBytes && !queue.isEmpty {
|
||||
let removed = queue.removeLast()
|
||||
trimmedBytes += removed.data.count
|
||||
total -= removed.data.count
|
||||
}
|
||||
|
||||
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
||||
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
|
||||
}
|
||||
|
||||
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
||||
let items = writesByPeripheralID[peripheralID] ?? []
|
||||
writesByPeripheralID[peripheralID] = nil
|
||||
return items
|
||||
}
|
||||
|
||||
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
||||
guard !items.isEmpty else { return }
|
||||
var existing = writesByPeripheralID[peripheralID] ?? []
|
||||
existing.insert(contentsOf: items, at: 0)
|
||||
writesByPeripheralID[peripheralID] = existing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
enum BLEPacketFreshnessPolicy {
|
||||
static let defaultMaxAgeSeconds: TimeInterval = 900
|
||||
|
||||
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
|
||||
guard let recipientID else { return true }
|
||||
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
|
||||
}
|
||||
|
||||
static func isStale(
|
||||
timestampMilliseconds: UInt64,
|
||||
now: Date,
|
||||
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
|
||||
) -> Bool {
|
||||
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
|
||||
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
|
||||
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
|
||||
}
|
||||
|
||||
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
|
||||
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
|
||||
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEPeerEventDebouncer {
|
||||
private var lastEmitByPeer: [PeerID: Date] = [:]
|
||||
|
||||
var count: Int {
|
||||
lastEmitByPeer.count
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
|
||||
if let lastEmit = lastEmitByPeer[peerID],
|
||||
now.timeIntervalSince(lastEmit) < minimumInterval {
|
||||
return false
|
||||
}
|
||||
|
||||
lastEmitByPeer[peerID] = now
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
lastEmitByPeer.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
enum BLEPeerPublishDecision: Equatable {
|
||||
case publishNow
|
||||
case schedule(delay: TimeInterval)
|
||||
case skip
|
||||
}
|
||||
|
||||
struct BLEPeerPublishCoalescer {
|
||||
private var lastPublishAt: Date
|
||||
private var publishPending: Bool
|
||||
private let minimumInterval: TimeInterval
|
||||
|
||||
init(
|
||||
lastPublishAt: Date = .distantPast,
|
||||
publishPending: Bool = false,
|
||||
minimumInterval: TimeInterval = 0.1
|
||||
) {
|
||||
self.lastPublishAt = lastPublishAt
|
||||
self.publishPending = publishPending
|
||||
self.minimumInterval = minimumInterval
|
||||
}
|
||||
|
||||
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
|
||||
let elapsed = now.timeIntervalSince(lastPublishAt)
|
||||
if elapsed >= minimumInterval {
|
||||
lastPublishAt = now
|
||||
return .publishNow
|
||||
}
|
||||
|
||||
guard !publishPending else {
|
||||
return .skip
|
||||
}
|
||||
|
||||
publishPending = true
|
||||
return .schedule(delay: minimumInterval - elapsed)
|
||||
}
|
||||
|
||||
mutating func scheduledPublishFired(now: Date) {
|
||||
lastPublishAt = now
|
||||
publishPending = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEPeerInfo: Equatable {
|
||||
let peerID: PeerID
|
||||
var nickname: String
|
||||
var isConnected: Bool
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
}
|
||||
|
||||
struct BLEPeerAnnounceUpdate: Equatable {
|
||||
let isNewPeer: Bool
|
||||
let wasDisconnected: Bool
|
||||
let previousNickname: String?
|
||||
}
|
||||
|
||||
struct BLEPeerLinkPresence: Equatable {
|
||||
var hasPeripheral: Bool
|
||||
var hasCentral: Bool
|
||||
}
|
||||
|
||||
struct BLERemovedPeer: Equatable {
|
||||
let peerID: PeerID
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
struct BLEPeerConnectivityChanges: Equatable {
|
||||
var disconnectedPeerIDs: [PeerID] = []
|
||||
var removedPeers: [BLERemovedPeer] = []
|
||||
}
|
||||
|
||||
struct BLEPeerRegistry {
|
||||
private var peers: [PeerID: BLEPeerInfo] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
peers.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
peers.count
|
||||
}
|
||||
|
||||
var peerIDs: [PeerID] {
|
||||
Array(peers.keys)
|
||||
}
|
||||
|
||||
var connectedCount: Int {
|
||||
peers.values.filter(\.isConnected).count
|
||||
}
|
||||
|
||||
var connectedPeerIDs: [PeerID] {
|
||||
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
||||
}
|
||||
|
||||
var connectedRoutingData: [Data] {
|
||||
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
||||
}
|
||||
|
||||
var snapshotByID: [PeerID: BLEPeerInfo] {
|
||||
peers
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
peers.removeAll()
|
||||
}
|
||||
|
||||
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||
peers[peerID]
|
||||
}
|
||||
|
||||
mutating func upsert(_ info: BLEPeerInfo) {
|
||||
peers[info.peerID] = info
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
||||
peers.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
func isConnected(_ peerID: PeerID) -> Bool {
|
||||
peers[peerID.toShort()]?.isConnected ?? false
|
||||
}
|
||||
|
||||
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||
let shortID = peerID.toShort()
|
||||
let meshAttached = connectedCount > 0
|
||||
guard let info = peers[shortID] else { return false }
|
||||
if info.isConnected { return true }
|
||||
guard meshAttached else { return false }
|
||||
|
||||
let retention: TimeInterval = info.isVerifiedNickname
|
||||
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
return now.timeIntervalSince(info.lastSeen) <= retention
|
||||
}
|
||||
|
||||
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||
guard let peer = peers[peerID] else { return nil }
|
||||
if connectedOnly && !peer.isConnected { return nil }
|
||||
return peer.nickname
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
let connected = peers.filter { $0.value.isConnected }
|
||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
||||
}
|
||||
|
||||
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||
let snapshot = Array(peers.values)
|
||||
let resolvedNames = PeerDisplayNameResolver.resolve(
|
||||
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
||||
selfNickname: selfNickname
|
||||
)
|
||||
return snapshot.map { info in
|
||||
TransportPeerSnapshot(
|
||||
peerID: info.peerID,
|
||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||
let hasCollision = peers.values.contains {
|
||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||
} || selfNickname == info.nickname
|
||||
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||
}
|
||||
|
||||
mutating func markDisconnected(_ peerID: PeerID) {
|
||||
guard var info = peers[peerID] else { return }
|
||||
info.isConnected = false
|
||||
peers[peerID] = info
|
||||
}
|
||||
|
||||
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
||||
guard var peer = peers[peerID] else { return }
|
||||
peer.lastSeen = date
|
||||
peers[peerID] = peer
|
||||
}
|
||||
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
isNewPeer: existing == nil,
|
||||
wasDisconnected: existing?.isConnected == false,
|
||||
previousNickname: existing?.nickname
|
||||
)
|
||||
|
||||
peers[peerID] = BLEPeerInfo(
|
||||
peerID: existing?.peerID ?? peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
)
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
mutating func reconcileConnectivity(
|
||||
now: Date,
|
||||
linkStates: [PeerID: BLEPeerLinkPresence]
|
||||
) -> BLEPeerConnectivityChanges {
|
||||
var changes = BLEPeerConnectivityChanges()
|
||||
|
||||
for (peerID, peer) in Array(peers) {
|
||||
let age = now.timeIntervalSince(peer.lastSeen)
|
||||
let retention: TimeInterval = peer.isVerifiedNickname
|
||||
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
|
||||
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
||||
if !state.hasPeripheral && !state.hasCentral {
|
||||
var updated = peer
|
||||
updated.isConnected = false
|
||||
peers[peerID] = updated
|
||||
changes.disconnectedPeerIDs.append(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
if !peer.isConnected && age > retention {
|
||||
peers.removeValue(forKey: peerID)
|
||||
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEPeerSenderDisplayName {
|
||||
static func resolveKnownPeer(
|
||||
peerID: PeerID,
|
||||
localPeerID: PeerID,
|
||||
localNickname: String,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
allowConnectedUnverified: Bool
|
||||
) -> String? {
|
||||
if peerID == localPeerID {
|
||||
return localNickname
|
||||
}
|
||||
|
||||
guard let info = peers[peerID] else { return nil }
|
||||
|
||||
if info.isVerifiedNickname {
|
||||
return collisionResolvedName(
|
||||
displayName: info.nickname,
|
||||
collisionNickname: info.nickname,
|
||||
peerID: peerID,
|
||||
localNickname: localNickname,
|
||||
peers: peers
|
||||
)
|
||||
}
|
||||
|
||||
if allowConnectedUnverified, info.isConnected {
|
||||
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
|
||||
return collisionResolvedName(
|
||||
displayName: displayName,
|
||||
collisionNickname: info.nickname,
|
||||
peerID: peerID,
|
||||
localNickname: localNickname,
|
||||
peers: peers
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func anonymousNickname(for peerID: PeerID) -> String {
|
||||
"anon" + String(peerID.id.prefix(4))
|
||||
}
|
||||
|
||||
private static func collisionResolvedName(
|
||||
displayName: String,
|
||||
collisionNickname: String,
|
||||
peerID: PeerID,
|
||||
localNickname: String,
|
||||
peers: [PeerID: BLEPeerInfo]
|
||||
) -> String {
|
||||
let hasCollision = peers.values.contains {
|
||||
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
|
||||
} || localNickname == collisionNickname
|
||||
|
||||
guard hasCollision else { return displayName }
|
||||
return displayName + "#" + String(peerID.id.prefix(4))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEPublicMessageAcceptance: Equatable {
|
||||
let shouldTrackForSync: Bool
|
||||
}
|
||||
|
||||
enum BLEPublicMessageRejection: Equatable {
|
||||
case selfEcho
|
||||
case staleBroadcast(ageSeconds: Double)
|
||||
}
|
||||
|
||||
enum BLEPublicMessageDecision: Equatable {
|
||||
case accept(BLEPublicMessageAcceptance)
|
||||
case reject(BLEPublicMessageRejection)
|
||||
}
|
||||
|
||||
enum BLEPublicMessagePolicy {
|
||||
static func evaluate(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
localPeerID: PeerID,
|
||||
now: Date
|
||||
) -> BLEPublicMessageDecision {
|
||||
if peerID == localPeerID && packet.ttl != 0 {
|
||||
return .reject(.selfEcho)
|
||||
}
|
||||
|
||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||
if isBroadcast,
|
||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
)))
|
||||
}
|
||||
|
||||
return .accept(BLEPublicMessageAcceptance(
|
||||
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEReceivedPacketContext: Equatable {
|
||||
let senderID: PeerID
|
||||
let messageID: String
|
||||
let messageType: MessageType?
|
||||
let shouldDeduplicate: Bool
|
||||
let logsHandlingDetails: Bool
|
||||
}
|
||||
|
||||
struct BLEReceivePipeline {
|
||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
let messageType = MessageType(rawValue: packet.type)
|
||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||
|
||||
return BLEReceivedPacketContext(
|
||||
senderID: senderID,
|
||||
messageID: messageID,
|
||||
messageType: messageType,
|
||||
shouldDeduplicate: shouldDeduplicate,
|
||||
logsHandlingDetails: messageType != .announce
|
||||
)
|
||||
}
|
||||
|
||||
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
||||
connectedPeerCount > 2
|
||||
}
|
||||
|
||||
static func relayDecision(
|
||||
for packet: BitchatPacket,
|
||||
senderID: PeerID,
|
||||
localPeerID: PeerID,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int
|
||||
) -> RelayDecision {
|
||||
RelayController.decide(
|
||||
ttl: packet.ttl,
|
||||
senderIsSelf: senderID == localPeerID,
|
||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLERecentTrafficTracker: Equatable {
|
||||
private var packetTimestamps: [Date] = []
|
||||
|
||||
var count: Int {
|
||||
packetTimestamps.count
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
packetTimestamps.removeAll()
|
||||
}
|
||||
|
||||
mutating func recordPacket(at now: Date) {
|
||||
packetTimestamps.append(now)
|
||||
prune(at: now)
|
||||
}
|
||||
|
||||
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||
let cutoff = now.addingTimeInterval(-seconds)
|
||||
return packetTimestamps.contains { $0 >= cutoff }
|
||||
}
|
||||
|
||||
private mutating func prune(at now: Date) {
|
||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
||||
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
||||
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
||||
}
|
||||
packetTimestamps.removeAll { $0 < cutoff }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLERouteForwardingPlan {
|
||||
let shouldSuppressFloodRelay: Bool
|
||||
let forwardPacket: BitchatPacket?
|
||||
let nextHop: PeerID?
|
||||
|
||||
static let allowFloodRelay = BLERouteForwardingPlan(
|
||||
shouldSuppressFloodRelay: false,
|
||||
forwardPacket: nil,
|
||||
nextHop: nil
|
||||
)
|
||||
|
||||
static let suppressFloodRelay = BLERouteForwardingPlan(
|
||||
shouldSuppressFloodRelay: true,
|
||||
forwardPacket: nil,
|
||||
nextHop: nil
|
||||
)
|
||||
|
||||
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
|
||||
BLERouteForwardingPlan(
|
||||
shouldSuppressFloodRelay: true,
|
||||
forwardPacket: packet,
|
||||
nextHop: nextHop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLERouteForwardingPolicy {
|
||||
static func plan(
|
||||
for packet: BitchatPacket,
|
||||
localPeerID: PeerID,
|
||||
localRoutingData: Data?,
|
||||
routingPeer: (Data) -> PeerID?,
|
||||
isPeerConnected: (PeerID) -> Bool
|
||||
) -> BLERouteForwardingPlan {
|
||||
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
guard let route = packet.route, !route.isEmpty else {
|
||||
return .allowFloodRelay
|
||||
}
|
||||
|
||||
guard packet.ttl > 1 else {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
guard let localRoutingData else {
|
||||
return .allowFloodRelay
|
||||
}
|
||||
|
||||
guard let localIndex = route.firstIndex(of: localRoutingData) else {
|
||||
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
|
||||
}
|
||||
|
||||
if localIndex == route.count - 1 {
|
||||
guard let destinationPeer = PeerID(hexData: packet.recipientID),
|
||||
isPeerConnected(destinationPeer) else {
|
||||
return .allowFloodRelay
|
||||
}
|
||||
return .forward(relayed(packet), to: destinationPeer)
|
||||
}
|
||||
|
||||
return forward(
|
||||
packet,
|
||||
toRouteData: route[localIndex + 1],
|
||||
routingPeer: routingPeer,
|
||||
isPeerConnected: isPeerConnected
|
||||
)
|
||||
}
|
||||
|
||||
private static func forward(
|
||||
_ packet: BitchatPacket,
|
||||
toRouteData routeData: Data,
|
||||
routingPeer: (Data) -> PeerID?,
|
||||
isPeerConnected: (PeerID) -> Bool
|
||||
) -> BLERouteForwardingPlan {
|
||||
guard let nextPeer = routingPeer(routeData),
|
||||
isPeerConnected(nextPeer) else {
|
||||
return .allowFloodRelay
|
||||
}
|
||||
|
||||
return .forward(relayed(packet), to: nextPeer)
|
||||
}
|
||||
|
||||
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
return relayPacket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum BLEScanDutyPlan: Equatable {
|
||||
case continuous
|
||||
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
|
||||
}
|
||||
|
||||
enum BLEScanDutyPolicy {
|
||||
static func plan(
|
||||
dutyEnabled: Bool,
|
||||
appIsActive: Bool,
|
||||
connectedCount: Int,
|
||||
hasRecentTraffic: Bool,
|
||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||
) -> BLEScanDutyPlan {
|
||||
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
|
||||
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
|
||||
|
||||
guard shouldDutyCycle else {
|
||||
return .continuous
|
||||
}
|
||||
|
||||
if connectedCount >= highDegreeThreshold {
|
||||
return .dutyCycle(
|
||||
onDuration: TransportConfig.bleDutyOnDurationDense,
|
||||
offDuration: TransportConfig.bleDutyOffDurationDense
|
||||
)
|
||||
}
|
||||
|
||||
return .dutyCycle(
|
||||
onDuration: TransportConfig.bleDutyOnDuration,
|
||||
offDuration: TransportConfig.bleDutyOffDuration
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEScheduledRelayStore {
|
||||
private var relays: [String: DispatchWorkItem] = [:]
|
||||
|
||||
var count: Int {
|
||||
relays.count
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
relays.isEmpty
|
||||
}
|
||||
|
||||
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
|
||||
relays[messageID] = workItem
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func remove(messageID: String) -> DispatchWorkItem? {
|
||||
relays.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func cancel(messageID: String) -> Bool {
|
||||
guard let workItem = relays.removeValue(forKey: messageID) else {
|
||||
return false
|
||||
}
|
||||
|
||||
workItem.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func cancelAll() {
|
||||
relays.values.forEach { $0.cancel() }
|
||||
relays.removeAll()
|
||||
}
|
||||
|
||||
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
|
||||
if relays.count > maxCount {
|
||||
relays.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLESelfBroadcastTracker {
|
||||
private struct Entry {
|
||||
let messageID: String
|
||||
let sentAt: Date
|
||||
}
|
||||
|
||||
private var entriesByDedupID: [String: Entry] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
entriesByDedupID.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
entriesByDedupID.count
|
||||
}
|
||||
|
||||
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
|
||||
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
|
||||
}
|
||||
|
||||
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
|
||||
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
|
||||
}
|
||||
|
||||
mutating func prune(before cutoff: Date) {
|
||||
guard !entriesByDedupID.isEmpty else { return }
|
||||
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
entriesByDedupID.removeAll()
|
||||
}
|
||||
|
||||
static func dedupID(for packet: BitchatPacket) -> String {
|
||||
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
|
||||
}
|
||||
}
|
||||
+1109
-2133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
enum BLESubscriptionAnnounceDecision: Equatable {
|
||||
case allowed
|
||||
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
|
||||
}
|
||||
|
||||
struct BLESubscriptionAnnounceLimiter {
|
||||
private struct State {
|
||||
var lastAnnounceTime: Date
|
||||
var attemptCount: Int
|
||||
var currentBackoffSeconds: TimeInterval
|
||||
}
|
||||
|
||||
private var states: [String: State] = [:]
|
||||
|
||||
var trackedCentralCount: Int {
|
||||
states.count
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
states.removeAll()
|
||||
}
|
||||
|
||||
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
|
||||
pruneStaleEntries(now: now)
|
||||
|
||||
guard let existing = states[centralID] else {
|
||||
recordAllowedAttempt(for: centralID, now: now)
|
||||
return .allowed
|
||||
}
|
||||
|
||||
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
|
||||
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
|
||||
recordAllowedAttempt(for: centralID, now: now)
|
||||
return .allowed
|
||||
}
|
||||
|
||||
let newAttemptCount = existing.attemptCount + 1
|
||||
let newBackoff = min(
|
||||
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
|
||||
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
||||
)
|
||||
states[centralID] = State(
|
||||
lastAnnounceTime: now,
|
||||
attemptCount: newAttemptCount,
|
||||
currentBackoffSeconds: newBackoff
|
||||
)
|
||||
|
||||
return .rateLimited(
|
||||
backoffSeconds: existing.currentBackoffSeconds,
|
||||
attemptCount: existing.attemptCount,
|
||||
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
|
||||
states[centralID] = State(
|
||||
lastAnnounceTime: now,
|
||||
attemptCount: 1,
|
||||
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func pruneStaleEntries(now: Date) {
|
||||
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
|
||||
states = states.filter { _, state in
|
||||
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ struct CommandGeoParticipant {
|
||||
protocol CommandContextProvider: AnyObject {
|
||||
// MARK: - State Properties
|
||||
var nickname: String { get }
|
||||
var activeChannel: ChannelID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var blockedUsers: Set<String> { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
@@ -75,7 +76,7 @@ final class CommandProcessor {
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
let inGeoPublic: Bool = {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .mesh: return false
|
||||
case .location: return true
|
||||
}
|
||||
@@ -135,7 +136,7 @@ final class CommandProcessor {
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
|
||||
@@ -7,76 +7,10 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// MARK: - Keychain Error Types
|
||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
||||
|
||||
/// Result of a keychain read operation with proper error classification
|
||||
enum KeychainReadResult {
|
||||
case success(Data)
|
||||
case itemNotFound // Expected: key doesn't exist yet
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case authenticationFailed // Recoverable: biometric/passcode failed
|
||||
case otherError(OSStatus) // Unexpected error
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .deviceLocked, .authenticationFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a keychain save operation with proper error classification
|
||||
enum KeychainSaveResult {
|
||||
case success
|
||||
case duplicateItem // Can retry with update
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case storageFull // Critical: no space available
|
||||
case otherError(OSStatus)
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .duplicateItem, .deviceLocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol KeychainManagerProtocol {
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||
func getIdentityKey(forKey key: String) -> Data?
|
||||
func deleteIdentityKey(forKey key: String) -> Bool
|
||||
func deleteAllKeychainData() -> Bool
|
||||
|
||||
func secureClear(_ data: inout Data)
|
||||
func secureClear(_ string: inout String)
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool
|
||||
|
||||
// BCH-01-009: Methods with proper error classification
|
||||
/// Get identity key with detailed result for error handling
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
|
||||
/// Save identity key with detailed result for error handling
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
/// Load data from a custom service
|
||||
func load(key: String, service: String) -> Data?
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
|
||||
@@ -106,6 +106,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
// MARK: - Transport Protocol Conformance
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var eventDelegate: TransportEventDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
@@ -173,9 +174,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
// Use barrier to synchronize access to readQueue
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
self?.processReadQueueIfNeeded()
|
||||
queue.async(flags: .barrier) {
|
||||
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
self.processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
@@ -20,6 +21,19 @@ struct NotificationStreamAssembler {
|
||||
pendingFrameExpectedLength = 0
|
||||
}
|
||||
|
||||
private mutating func discardLeadingPaddingIfPresent() -> Bool {
|
||||
guard let first = buffer.first else { return false }
|
||||
guard first != 1 && first != 2 else { return false }
|
||||
let paddingLength = Int(first)
|
||||
guard paddingLength > 0, paddingLength <= buffer.count else { return false }
|
||||
guard buffer.prefix(paddingLength).allSatisfy({ $0 == first }) else { return false }
|
||||
|
||||
buffer.removeFirst(paddingLength)
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
|
||||
@@ -41,6 +55,9 @@ struct NotificationStreamAssembler {
|
||||
while buffer.count >= minimumFramePrefix {
|
||||
guard let version = buffer.first else { break }
|
||||
guard version == 1 || version == 2 else {
|
||||
if discardLeadingPaddingIfPresent() {
|
||||
continue
|
||||
}
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
@@ -132,6 +149,11 @@ struct NotificationStreamAssembler {
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
_ = discardLeadingPaddingIfPresent()
|
||||
}
|
||||
|
||||
if discardLeadingPaddingIfPresent() {
|
||||
return (frames, dropped, didReset)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
|
||||
@@ -11,6 +11,7 @@ struct RelayDecision {
|
||||
struct RelayController {
|
||||
static func decide(ttl: UInt8,
|
||||
senderIsSelf: Bool,
|
||||
recipientIsSelf: Bool = false,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isFragment: Bool,
|
||||
@@ -22,7 +23,7 @@ struct RelayController {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf {
|
||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
@@ -12,9 +13,27 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let lastSeen: Date
|
||||
}
|
||||
|
||||
enum TransportEvent: @unchecked Sendable {
|
||||
case messageReceived(BitchatMessage)
|
||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
case peerConnected(PeerID)
|
||||
case peerDisconnected(PeerID)
|
||||
case peerListUpdated([PeerID])
|
||||
case peerSnapshotsUpdated([TransportPeerSnapshot])
|
||||
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
|
||||
case bluetoothStateUpdated(CBManagerState)
|
||||
}
|
||||
|
||||
protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
// Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code.
|
||||
var eventDelegate: TransportEventDelegate? { get set }
|
||||
// Peer events (preferred over publishers for UI)
|
||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||
|
||||
@@ -84,4 +103,36 @@ protocol TransportPeerEventsDelegate: AnyObject {
|
||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||
}
|
||||
|
||||
extension BitchatDelegate {
|
||||
@MainActor
|
||||
func receiveTransportEvent(_ event: TransportEvent) {
|
||||
switch event {
|
||||
case .messageReceived(let message):
|
||||
didReceiveMessage(message)
|
||||
case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID):
|
||||
didReceivePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID
|
||||
)
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||
case .peerConnected(let peerID):
|
||||
didConnectToPeer(peerID)
|
||||
case .peerDisconnected(let peerID):
|
||||
didDisconnectFromPeer(peerID)
|
||||
case .peerListUpdated(let peers):
|
||||
didUpdatePeerList(peers)
|
||||
case .peerSnapshotsUpdated:
|
||||
break
|
||||
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||
didUpdateMessageDeliveryStatus(messageID, status: status)
|
||||
case .bluetoothStateUpdated(let state):
|
||||
didUpdateBluetoothState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BLEService: Transport {}
|
||||
|
||||
@@ -129,13 +129,16 @@ enum TransportConfig {
|
||||
static let nostrGeohashSampleLimit: Int = 100
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||
|
||||
// Nostr inbound event safety limits
|
||||
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||
static let nostrMaxEventTags: Int = 64
|
||||
static let nostrMaxEventTagValues: Int = 16
|
||||
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||
|
||||
// Nostr helpers
|
||||
static let nostrShortKeyDisplayLength: Int = 8
|
||||
static let nostrConvKeyPrefixLength: Int = 16
|
||||
|
||||
// Compression
|
||||
static let compressionThresholdBytes: Int = 100
|
||||
|
||||
// Message deduplication
|
||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||
static let messageDedupMaxCount: Int = 1000
|
||||
|
||||
@@ -128,17 +128,15 @@ final class GossipSyncManager {
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
||||
|
||||
var types: SyncTypeFlags = .publicMessages
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||
}
|
||||
types.formUnion(.fragment)
|
||||
}
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||
}
|
||||
types.formUnion(.fileTransfer)
|
||||
}
|
||||
self.sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,13 +391,18 @@ final class GossipSyncManager {
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
|
||||
var dueTypes: SyncTypeFlags = []
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
dueTypes.formUnion(syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
|
||||
if !dueTypes.isEmpty {
|
||||
sendPeriodicSync(for: dueTypes)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import struct BitFoundation.BitchatPacket
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
struct ChatBluetoothAlertUpdate: Equatable {
|
||||
let isPresented: Bool
|
||||
let message: String?
|
||||
}
|
||||
|
||||
enum ChatBluetoothAlertPolicy {
|
||||
static func update(for state: CBManagerState) -> ChatBluetoothAlertUpdate {
|
||||
switch state {
|
||||
case .poweredOff:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||||
)
|
||||
case .unauthorized:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||||
)
|
||||
case .unsupported:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||||
)
|
||||
case .poweredOn:
|
||||
ChatBluetoothAlertUpdate(isPresented: false, message: "")
|
||||
case .unknown, .resetting:
|
||||
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
|
||||
@unknown default:
|
||||
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,107 @@
|
||||
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 deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
if let message = viewModel.messages.first(where: { $0.id == messageID }) {
|
||||
return message.deliveryStatus
|
||||
}
|
||||
|
||||
for messages in viewModel.privateChats.values {
|
||||
if let message = messages.first(where: { $0.id == messageID }) {
|
||||
return message.deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
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()
|
||||
}
|
||||
|
||||
return didUpdateStatus
|
||||
}
|
||||
}
|
||||
|
||||
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,67 @@
|
||||
import Foundation
|
||||
|
||||
struct ChatFavoriteStatusSnapshot: Equatable {
|
||||
let peerNickname: String
|
||||
let peerNostrPublicKey: String?
|
||||
let isFavorite: Bool
|
||||
let theyFavoritedUs: Bool
|
||||
|
||||
init(
|
||||
peerNickname: String,
|
||||
peerNostrPublicKey: String?,
|
||||
isFavorite: Bool,
|
||||
theyFavoritedUs: Bool
|
||||
) {
|
||||
self.peerNickname = peerNickname
|
||||
self.peerNostrPublicKey = peerNostrPublicKey
|
||||
self.isFavorite = isFavorite
|
||||
self.theyFavoritedUs = theyFavoritedUs
|
||||
}
|
||||
|
||||
init(_ relationship: FavoritesPersistenceService.FavoriteRelationship) {
|
||||
self.peerNickname = relationship.peerNickname
|
||||
self.peerNostrPublicKey = relationship.peerNostrPublicKey
|
||||
self.isFavorite = relationship.isFavorite
|
||||
self.theyFavoritedUs = relationship.theyFavoritedUs
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatFavoritePersistenceAction: Equatable {
|
||||
case add(nickname: String, nostrKey: String?)
|
||||
case remove
|
||||
}
|
||||
|
||||
enum ChatFavoriteNotificationDecision: Equatable {
|
||||
case none
|
||||
case send(isFavorite: Bool)
|
||||
}
|
||||
|
||||
struct ChatFavoriteTogglePlan: Equatable {
|
||||
let persistenceAction: ChatFavoritePersistenceAction
|
||||
let notification: ChatFavoriteNotificationDecision
|
||||
}
|
||||
|
||||
enum ChatFavoriteTogglePolicy {
|
||||
static func plan(
|
||||
currentStatus: ChatFavoriteStatusSnapshot?,
|
||||
fallbackNickname: String?,
|
||||
bridgedNostrKey: String?
|
||||
) -> ChatFavoriteTogglePlan {
|
||||
let wasFavorite = currentStatus?.isFavorite ?? false
|
||||
|
||||
if wasFavorite {
|
||||
return ChatFavoriteTogglePlan(
|
||||
persistenceAction: .remove,
|
||||
notification: .send(isFavorite: false)
|
||||
)
|
||||
}
|
||||
|
||||
return ChatFavoriteTogglePlan(
|
||||
persistenceAction: .add(
|
||||
nickname: currentStatus?.peerNickname ?? fallbackNickname ?? "Unknown",
|
||||
nostrKey: currentStatus?.peerNostrPublicKey ?? bridgedNostrKey
|
||||
),
|
||||
notification: currentStatus?.theyFavoritedUs == true ? .send(isFavorite: true) : .none
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum ChatMediaPreparationError: Error, Equatable {
|
||||
case encodingFailed
|
||||
case voiceNoteTooLarge(bytes: Int)
|
||||
case imageTooLarge(bytes: Int)
|
||||
}
|
||||
|
||||
struct ChatPreparedImage {
|
||||
let outputURL: URL
|
||||
let packet: BitchatFilePacket
|
||||
}
|
||||
|
||||
enum ChatMediaPreparation {
|
||||
static func prepareVoiceNotePacket(at url: URL) throws -> BitchatFilePacket {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attributes[.size] as? Int else {
|
||||
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 0)
|
||||
}
|
||||
guard fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
|
||||
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: fileSize)
|
||||
}
|
||||
|
||||
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 ChatMediaPreparationError.encodingFailed }
|
||||
return packet
|
||||
}
|
||||
|
||||
static func prepareImagePacket(from sourceURL: URL) throws -> ChatPreparedImage {
|
||||
let outputURL = try ImageUtils.processImage(at: sourceURL)
|
||||
do {
|
||||
let data = try Data(contentsOf: outputURL)
|
||||
guard data.count <= FileTransferLimits.maxImageBytes else {
|
||||
throw ChatMediaPreparationError.imageTooLarge(bytes: data.count)
|
||||
}
|
||||
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: outputURL.lastPathComponent,
|
||||
fileSize: UInt64(data.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: data
|
||||
)
|
||||
guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed }
|
||||
return ChatPreparedImage(outputURL: outputURL, packet: packet)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class ChatMediaTransferCoordinator {
|
||||
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 packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
|
||||
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 ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||
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")
|
||||
}
|
||||
} 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
|
||||
|
||||
do {
|
||||
try ImageUtils.validateImageSource(at: sourceURL)
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
viewModel.addSystemMessage("Failed to prepare image for sending.")
|
||||
return
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.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(prepared.packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||
await MainActor.run {
|
||||
self.viewModel.addSystemMessage("Image is too large to send.")
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
await MainActor.run {
|
||||
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,860 @@
|
||||
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)
|
||||
|
||||
SecureLogger.debug(
|
||||
"GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tagCount=\(event.tags.count)",
|
||||
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 = Base64URLCoding.decode(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,463 @@
|
||||
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 hasUnreadMessages(for peerID: PeerID) -> Bool {
|
||||
var noiseKeyPeerID: PeerID?
|
||||
var nostrPeerID: PeerID?
|
||||
|
||||
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
|
||||
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
|
||||
if let nostrHex = peer.nostrPublicKey {
|
||||
nostrPeerID = PeerID(nostr_: nostrHex)
|
||||
}
|
||||
}
|
||||
|
||||
let context = ChatUnreadPeerContext(
|
||||
peerID: peerID,
|
||||
noiseKeyPeerID: noiseKeyPeerID,
|
||||
nostrPeerID: nostrPeerID,
|
||||
nickname: viewModel.meshService.peerNickname(peerID: peerID)
|
||||
)
|
||||
|
||||
return ChatUnreadStateResolver.hasUnreadMessages(
|
||||
for: context,
|
||||
unreadPrivateMessages: viewModel.unreadPrivateMessages,
|
||||
privateChats: viewModel.privateChats
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
if let noisePublicKey = peerID.noiseKey {
|
||||
toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID)
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.unifiedPeerService.toggleFavorite(peerID)
|
||||
viewModel.objectWillChange.send()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isFavorite(peerID: PeerID) -> Bool {
|
||||
if let noisePublicKey = peerID.noiseKey {
|
||||
return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
|
||||
if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID {
|
||||
viewModel.unifiedPeerService.toggleFavorite(ephemeralID)
|
||||
viewModel.objectWillChange.send()
|
||||
return
|
||||
}
|
||||
|
||||
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
|
||||
let plan = ChatFavoriteTogglePolicy.plan(
|
||||
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
||||
fallbackNickname: fallbackNickname,
|
||||
bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey)
|
||||
)
|
||||
|
||||
switch plan.persistenceAction {
|
||||
case .add(let nickname, let nostrKey):
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: nostrKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
case .remove:
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
}
|
||||
|
||||
viewModel.objectWillChange.send()
|
||||
|
||||
if case .send(let isFavorite) = plan.notification {
|
||||
viewModel.sendFavoriteNotificationViaNostr(
|
||||
noisePublicKey: noisePublicKey,
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,253 @@
|
||||
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) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: viewModel)
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: name, at: Date())
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) {
|
||||
SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
case .readReceipt:
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: viewModel)
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: name, at: Date())
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
SecureLogger.debug("📖 Read receipt for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
case .verifyChallenge:
|
||||
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
|
||||
|
||||
case .verifyResponse:
|
||||
viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String {
|
||||
viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct ChatUnreadPeerContext {
|
||||
let peerID: PeerID
|
||||
let noiseKeyPeerID: PeerID?
|
||||
let nostrPeerID: PeerID?
|
||||
let nickname: String?
|
||||
}
|
||||
|
||||
enum ChatUnreadStateResolver {
|
||||
static func hasUnreadMessages(
|
||||
for context: ChatUnreadPeerContext,
|
||||
unreadPrivateMessages: Set<PeerID>,
|
||||
privateChats: [PeerID: [BitchatMessage]]
|
||||
) -> Bool {
|
||||
if unreadPrivateMessages.contains(context.peerID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let noiseKeyPeerID = context.noiseKeyPeerID,
|
||||
unreadPrivateMessages.contains(noiseKeyPeerID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let nostrPeerID = context.nostrPeerID,
|
||||
unreadPrivateMessages.contains(nostrPeerID) {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let peerNickname = context.nickname?.lowercased(), !peerNickname.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return unreadPrivateMessages.contains { unreadPeerID in
|
||||
guard unreadPeerID.isGeoDM,
|
||||
let firstMessage = privateChats[unreadPeerID]?.first else {
|
||||
return false
|
||||
}
|
||||
return firstMessage.sender.lowercased() == peerNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
+426
-3059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
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
|
||||
viewModel.meshService.eventDelegate = viewModel
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
_ = viewModel.getMyFingerprint()
|
||||
}
|
||||
|
||||
viewModel.meshService.setNickname(viewModel.nickname)
|
||||
}
|
||||
|
||||
func startRuntimeServices() {
|
||||
viewModel.meshService.startServices()
|
||||
|
||||
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
|
||||
viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
||||
guard let viewModel,
|
||||
let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let state = bleService.getCurrentBluetoothState()
|
||||
viewModel.updateBluetoothState(state)
|
||||
}
|
||||
|
||||
viewModel.nostrRelayManager = NostrRelayManager.shared
|
||||
viewModel.messageRouter.flushAllOutbox()
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)
|
||||
)
|
||||
viewModel.isStartupPhase = false
|
||||
}
|
||||
}
|
||||
|
||||
func bindPeerService() {
|
||||
viewModel.unifiedPeerService.$peers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] peers in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
|
||||
viewModel.allPeers = peers
|
||||
viewModel.identityResolver.register(peers: peers)
|
||||
|
||||
var uniquePeers: [PeerID: BitchatPeer] = [:]
|
||||
for peer in peers {
|
||||
if uniquePeers[peer.peerID] == nil {
|
||||
uniquePeers[peer.peerID] = peer
|
||||
} else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModel.peerIndex = uniquePeers
|
||||
|
||||
if viewModel.hasTrackedPrivateChatSelection {
|
||||
viewModel.updatePrivateChatPeerIfNeeded()
|
||||
}
|
||||
|
||||
viewModel.synchronizePrivateConversationStore()
|
||||
viewModel.synchronizeConversationSelectionStore()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
|
||||
func configureNoiseCallbacks() {
|
||||
viewModel.setupNoiseCallbacks()
|
||||
}
|
||||
|
||||
func bindTransferProgress() {
|
||||
TransferProgressManager.shared.publisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] event in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.handleTransferEvent(event)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
|
||||
func configureGeoChannels() {
|
||||
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
|
||||
locationManager: viewModel.locationManager,
|
||||
onChannelSwitch: { [weak viewModel] channel in
|
||||
viewModel?.switchLocationChannel(to: channel)
|
||||
},
|
||||
beginSampling: { [weak viewModel] geohashes in
|
||||
viewModel?.beginGeohashSampling(for: geohashes)
|
||||
},
|
||||
endSampling: { [weak viewModel] in
|
||||
viewModel?.endGeohashSampling()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func bindTeleportState() {
|
||||
viewModel.locationManager.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] isTeleported in
|
||||
guard let viewModel else { return }
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel,
|
||||
case .location(let channel) = viewModel.activeChannel,
|
||||
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
||||
let inRegional = viewModel.locationManager.availableChannels.contains {
|
||||
$0.geohash == channel.geohash
|
||||
}
|
||||
|
||||
if isTeleported && hasRegional && !inRegional {
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
} else {
|
||||
viewModel.locationPresenceStore.clearTeleported(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
|
||||
func requestNotifications() {
|
||||
NotificationService.shared.requestAuthorization()
|
||||
}
|
||||
|
||||
func registerObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
viewModel,
|
||||
selector: #selector(ChatViewModel.handleFavoriteStatusChanged(_:)),
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
viewModel,
|
||||
selector: #selector(ChatViewModel.handlePeerStatusUpdate(_:)),
|
||||
name: Notification.Name("peerStatusUpdated"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,846 +5,137 @@
|
||||
// Geohash and Nostr logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
import Foundation
|
||||
|
||||
extension ChatViewModel {
|
||||
|
||||
// MARK: - Geohash Subscription
|
||||
|
||||
// Resubscribe to the active geohash channel without clearing timeline
|
||||
@MainActor
|
||||
func resubscribeCurrentGeohash() {
|
||||
guard case .location(let ch) = activeChannel else { return }
|
||||
guard let subID = geoSubscriptionID else {
|
||||
// No existing subscription; set it up
|
||||
switchLocationChannel(to: activeChannel)
|
||||
return
|
||||
}
|
||||
// Ensure participant decay timer is running
|
||||
participantTracker.startRefreshTimer()
|
||||
// Unsubscribe + resubscribe
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
ch.geohash,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashInitialLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.subscribeNostrEvent(event)
|
||||
}
|
||||
// Resubscribe geohash DMs for this identity
|
||||
if let dmSub = geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
|
||||
}
|
||||
|
||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
self?.subscribeGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
}
|
||||
nostrCoordinator.resubscribeCurrentGeohash()
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
|
||||
if let gh = currentGeohash,
|
||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmed
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
|
||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
})
|
||||
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmed
|
||||
|
||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
Task { @MainActor in
|
||||
// BCH-01-012: Check blocking before any notifications
|
||||
// handlePublicMessage has its own blocking check but returns silently,
|
||||
// so we must also guard checkForMentions to prevent notification bypass
|
||||
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
|
||||
handlePublicMessage(msg)
|
||||
|
||||
// Only check mentions and send haptic if sender is not blocked
|
||||
if !isBlocked {
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
}
|
||||
nostrCoordinator.subscribeNostrEvent(event)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
||||
break
|
||||
}
|
||||
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Channel Handling
|
||||
|
||||
@MainActor
|
||||
func switchLocationChannel(to channel: ChannelID) {
|
||||
// Reset pending public batches to avoid cross-channel bleed
|
||||
publicMessagePipeline.reset()
|
||||
|
||||
activeChannel = channel
|
||||
publicMessagePipeline.updateActiveChannel(channel)
|
||||
|
||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
||||
deduplicationService.clearNostrCaches()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
participantTracker.stopRefreshTimer()
|
||||
participantTracker.setActiveGeohash(nil)
|
||||
teleportedGeo.removeAll()
|
||||
case .location:
|
||||
refreshVisibleMessages(from: channel)
|
||||
}
|
||||
// If switching to a location channel, flush any pending geohash-only system messages
|
||||
if case .location = channel {
|
||||
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
||||
addPublicSystemMessage(content)
|
||||
}
|
||||
}
|
||||
// Unsubscribe previous
|
||||
if let sub = geoSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
geoSubscriptionID = nil
|
||||
}
|
||||
if let dmSub = geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
geoDmSubscriptionID = nil
|
||||
}
|
||||
currentGeohash = nil
|
||||
participantTracker.setActiveGeohash(nil)
|
||||
// Reset nickname cache for geochat participants
|
||||
geoNicknames.removeAll()
|
||||
|
||||
guard case .location(let ch) = channel else { return }
|
||||
currentGeohash = ch.geohash
|
||||
participantTracker.setActiveGeohash(ch.geohash)
|
||||
|
||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
} else {
|
||||
teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
geoSubscriptionID = subID
|
||||
participantTracker.startRefreshTimer()
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.handleNostrEvent(event)
|
||||
}
|
||||
|
||||
subscribeToGeoChat(ch)
|
||||
nostrCoordinator.switchLocationChannel(to: channel)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Deduplicate
|
||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
|
||||
// Log incoming tags for diagnostics
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
}
|
||||
|
||||
let isSelf: Bool = {
|
||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if hasTeleportTag {
|
||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||
if !isSelf {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
teleTag.count >= 2,
|
||||
teleTag[1] == "teleport",
|
||||
content.trimmed.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: min(rawTs, Date()),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
handlePublicMessage(msg)
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
nostrCoordinator.handleNostrEvent(event)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
// pared back logging: subscribe debug only
|
||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
self?.handleGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
nostrCoordinator.subscribeToGeoChat(ch)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
// Decrypt with per-geohash identity
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
|
||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendGeohash(context: GeoOutgoingContext) {
|
||||
let ch = context.channel
|
||||
let event = context.event
|
||||
let identity = context.identity
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
// Track ourselves as active participant
|
||||
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
|
||||
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
// Only when not in our regional set (and regional list is known)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
|
||||
if context.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
nostrCoordinator.sendGeohash(context: context)
|
||||
}
|
||||
|
||||
// MARK: - Sampling
|
||||
|
||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
||||
if !TorManager.shared.isForeground() {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
// Determine which to add and which to remove
|
||||
let desired = Set(geohashes)
|
||||
let current = Set(geoSamplingSubs.values)
|
||||
let toAdd = desired.subtracting(current)
|
||||
let toRemove = current.subtracting(desired)
|
||||
|
||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
geoSamplingSubs.removeValue(forKey: subID)
|
||||
}
|
||||
|
||||
for gh in toAdd {
|
||||
subscribe(gh)
|
||||
}
|
||||
nostrCoordinator.beginGeohashSampling(for: geohashes)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func subscribe(_ gh: String) {
|
||||
let subID = "geo-sample-\(gh)"
|
||||
geoSamplingSubs[subID] = gh
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
gh,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashSampleLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.subscribeNostrEvent(event, gh: gh)
|
||||
}
|
||||
nostrCoordinator.subscribe(gh)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||
let existingCount = participantTracker.participantCount(for: gh)
|
||||
|
||||
// Update participants for this specific geohash
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
// Skip self identity for this geohash
|
||||
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||
|
||||
// Only trigger when there were zero participants in this geohash recently
|
||||
guard existingCount == 0 else { return }
|
||||
|
||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||
|
||||
// Foreground-only notifications: app must be active, and not already viewing this geohash
|
||||
#if os(iOS)
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
||||
#elseif os(macOS)
|
||||
guard NSApplication.shared.isActive else { return }
|
||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
||||
#endif
|
||||
|
||||
cooldownPerGeohash(gh, content: content, event: event)
|
||||
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||
let now = Date()
|
||||
let last = lastGeoNotificationAt[gh] ?? .distantPast
|
||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||
|
||||
// Compose a short preview
|
||||
let preview: String = {
|
||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
||||
if content.count <= maxLen { return content }
|
||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
||||
return String(content[..<idx]) + "…"
|
||||
}()
|
||||
|
||||
Task { @MainActor in
|
||||
lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let ts = min(rawTs, Date())
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
|
||||
}
|
||||
|
||||
/// Stop sampling all extra geohashes.
|
||||
@MainActor
|
||||
func endGeohashSampling() {
|
||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
||||
geoSamplingSubs.removeAll()
|
||||
nostrCoordinator.endGeohashSampling()
|
||||
}
|
||||
|
||||
// MARK: - Nostr DM Handling
|
||||
|
||||
@MainActor
|
||||
func setupNostrMessageHandling() {
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
|
||||
|
||||
// Subscribe to Nostr messages
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
||||
)
|
||||
|
||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
nostrCoordinator.setupNostrMessageHandling()
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
// Deduplicate messages by ID
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
// Ensure we're on a background queue for decryption
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
}
|
||||
nostrCoordinator.handleNostrMessage(giftWrap)
|
||||
}
|
||||
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
// Handle verification payloads first
|
||||
if content.hasPrefix("verify:") {
|
||||
// Ignore verification payloads arriving via Nostr path for now
|
||||
// Verification should ideally happen over mesh for security binding
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
||||
}
|
||||
await nostrCoordinator.processNostrMessage(giftWrap)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
// Check favorites for this Nostr key
|
||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
||||
var npubToMatch = nostrPubkey
|
||||
|
||||
// Convert hex to npub if needed for comparison
|
||||
if !nostrPubkey.hasPrefix("npub") {
|
||||
if let pubkeyData = Data(hexString: nostrPubkey),
|
||||
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
||||
npubToMatch = encoded
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
for relationship in favorites {
|
||||
// Search through favorites for matching Nostr pubkey
|
||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
||||
// Compare against stored key (could be hex or npub)
|
||||
if storedNostrKey == npubToMatch {
|
||||
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
|
||||
return relationship.peerNoisePublicKey
|
||||
}
|
||||
|
||||
// Also try comparing raw hex if stored key is hex
|
||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
||||
return relationship.peerNoisePublicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
|
||||
return nil
|
||||
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
||||
}
|
||||
|
||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
||||
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
|
||||
if let _ = key {
|
||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
||||
// check if we have an identity
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
// Same for READ receipt if viewing
|
||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
// Try to find Noise key associated with this Nostr pubkey
|
||||
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
// Update favorite status
|
||||
if isFavorite {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
)
|
||||
} else {
|
||||
// Only remove if we don't have it set locally
|
||||
// Logic handled by persistence service usually, here we just update remote state
|
||||
// Actually for now we just process the notification
|
||||
}
|
||||
|
||||
// Extract Nostr public key if included
|
||||
var extractedNostrPubkey: String? = nil
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
// Fallback: simple format FAVORITE:TRUE:npub...
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
// If they favorited us and provided their Nostr key, ensure it's stored
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: extractedNostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
// Show notification
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
@MainActor
|
||||
func sendDeliveryAckViaNostrEmbedded(
|
||||
_ message: BitchatMessage,
|
||||
wasReadBefore: Bool,
|
||||
senderPubkey: String,
|
||||
key: Data?
|
||||
) {
|
||||
nostrCoordinator.sendDeliveryAckViaNostrEmbedded(
|
||||
message,
|
||||
wasReadBefore: wasReadBefore,
|
||||
senderPubkey: senderPubkey,
|
||||
key: key
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
// Find peer Nostr key
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
relationship.peerNostrPublicKey != nil else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
let peerID = PeerID(hexData: noisePublicKey)
|
||||
|
||||
// Route via message router
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||
guard encoded.count <= maxEncoded else { return nil }
|
||||
guard let packetData = Self.base64URLDecode(encoded),
|
||||
packetData.count <= maxBytes
|
||||
else { return nil }
|
||||
return BitchatPacket.from(packetData)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||
|
||||
@MainActor
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
// Look up current visible geohash participants for an exact displayName match
|
||||
for p in visibleGeohashPeople() {
|
||||
if p.displayName == name {
|
||||
return p.id
|
||||
}
|
||||
}
|
||||
// Also check nickname cache directly
|
||||
for (pub, nick) in geoNicknames {
|
||||
if nick == name { return pub }
|
||||
}
|
||||
return nil
|
||||
nostrCoordinator.nostrPubkeyForDisplayName(name)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
let convKey = PeerID(nostr_: hex)
|
||||
nostrKeyMapping[convKey] = hex
|
||||
startPrivateChat(with: convKey)
|
||||
nostrCoordinator.startGeohashDM(withPubkeyHex: hex)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||
return nostrKeyMapping[senderID]
|
||||
nostrCoordinator.fullNostrHex(forSenderPeerID: senderID)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||
guard let full = nostrKeyMapping[convKey] else {
|
||||
return convKey.bare
|
||||
}
|
||||
return displayNameForNostrPubkey(full)
|
||||
nostrCoordinator.geohashDisplayName(for: convKey)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct PublicTimelineStore {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommandSuggestionsView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
|
||||
@Binding var messageText: String
|
||||
|
||||
@@ -19,8 +19,8 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
private var filteredCommands: [CommandInfo] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
let isGeoPublic = locationManager.selectedChannel.isLocation
|
||||
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||
command.alias.starts(with: messageText.lowercased())
|
||||
}
|
||||
@@ -79,6 +79,11 @@ struct CommandSuggestionsView: View {
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: viewModel.conversationStore
|
||||
)
|
||||
let locationChannelsModel = LocationChannelsModel()
|
||||
|
||||
CommandSuggestionsView(
|
||||
messageText: $messageText,
|
||||
@@ -86,5 +91,6 @@ struct CommandSuggestionsView: View {
|
||||
backgroundColor: .primary,
|
||||
secondaryTextColor: .secondary
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(privateConversationModel)
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@@ -23,13 +24,13 @@ struct TextMessageView: View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
@@ -68,6 +69,20 @@ struct TextMessageView: View {
|
||||
|
||||
#Preview {
|
||||
let keychain = PreviewKeychainManager()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: viewModel.conversationStore
|
||||
)
|
||||
let conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
conversationStore: viewModel.conversationStore
|
||||
)
|
||||
|
||||
Group {
|
||||
List {
|
||||
@@ -86,11 +101,5 @@ struct TextMessageView: View {
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.environmentObject(
|
||||
ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(),
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
.environmentObject(conversationUIModel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var messageText: String
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
||||
@Binding var autocompleteDebounceTimer: Timer?
|
||||
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let onSendMessage: () -> Void
|
||||
|
||||
#if os(iOS)
|
||||
@Binding var showImagePicker: Bool
|
||||
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
|
||||
#else
|
||||
@Binding var showMacImagePicker: Bool
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
|
||||
Button(action: {
|
||||
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
|
||||
}) {
|
||||
HStack {
|
||||
Text(suggestion)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
CommandSuggestionsView(
|
||||
messageText: $messageText,
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
secondaryTextColor: secondaryTextColor
|
||||
)
|
||||
|
||||
if voiceRecordingVM.state.isActive {
|
||||
recordingIndicator
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
TextField(
|
||||
"",
|
||||
text: $messageText,
|
||||
prompt: Text(
|
||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
||||
)
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 15, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused(isTextFieldFocused)
|
||||
.autocorrectionDisabled(true)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
#endif
|
||||
.submitLabel(.send)
|
||||
.onSubmit(onSendMessage)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
|
||||
)
|
||||
.modifier(FocusEffectDisabledModifier())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onChange(of: messageText) { newValue in
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
let cursorPosition = newValue.count
|
||||
Task { @MainActor in
|
||||
conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
if conversationUIModel.canSendMediaInCurrentContext {
|
||||
attachmentButton
|
||||
}
|
||||
|
||||
sendOrMicButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
.onDisappear {
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ContentComposerView {
|
||||
var recordingIndicator: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
TimelineView(.periodic(from: .now, by: 0.05)) { context in
|
||||
Text(
|
||||
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
|
||||
comment: "Voice note recording duration indicator"
|
||||
)
|
||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: voiceRecordingVM.cancel) {
|
||||
Label("Cancel", systemImage: "xmark.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.bitchatSystem(size: 18))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.red.opacity(0.15))
|
||||
)
|
||||
}
|
||||
|
||||
var composerAccentColor: Color {
|
||||
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor
|
||||
}
|
||||
|
||||
var attachmentButton: some View {
|
||||
#if os(iOS)
|
||||
Image(systemName: "camera.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.onTapGesture {
|
||||
imagePickerSourceType = .photoLibrary
|
||||
showImagePicker = true
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.3) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel("Tap for library, long press for camera")
|
||||
#else
|
||||
Button(action: { showMacImagePicker = true }) {
|
||||
Image(systemName: "photo.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Choose photo")
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var sendOrMicButton: some View {
|
||||
let hasText = !messageText.trimmed.isEmpty
|
||||
if conversationUIModel.canSendMediaInCurrentContext {
|
||||
ZStack {
|
||||
micButtonView
|
||||
.opacity(hasText ? 0 : 1)
|
||||
.allowsHitTesting(!hasText)
|
||||
sendButtonView(enabled: hasText)
|
||||
.opacity(hasText ? 1 : 0)
|
||||
.allowsHitTesting(hasText)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
} else {
|
||||
sendButtonView(enabled: hasText)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
var micButtonView: some View {
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.overlay(
|
||||
Color.clear
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
|
||||
}
|
||||
.onEnded { _ in
|
||||
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
|
||||
}
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Hold to record a voice note")
|
||||
}
|
||||
|
||||
func sendButtonView(enabled: Bool) -> some View {
|
||||
let activeColor = composerAccentColor
|
||||
return Button(action: onSendMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(enabled ? activeColor : Color.gray)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
enabled
|
||||
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ContentHeaderView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var showVerifySheet: Bool
|
||||
@Binding var showLocationNotes: Bool
|
||||
@Binding var notesGeohash: String?
|
||||
var isNicknameFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
let headerHeight: CGFloat
|
||||
let headerPeerIconSize: CGFloat
|
||||
let headerPeerCountFontSize: CGFloat
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "bitchat/")
|
||||
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.onTapGesture(count: 3) {
|
||||
appChromeModel.panicClearAllData()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "@")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
|
||||
TextField(
|
||||
"content.input.nickname_placeholder",
|
||||
text: Binding(
|
||||
get: { appChromeModel.nickname },
|
||||
set: { appChromeModel.setNickname($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.frame(maxWidth: 80)
|
||||
.foregroundColor(textColor)
|
||||
.focused(isNicknameFieldFocused)
|
||||
.autocorrectionDisabled(true)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.modifier(FocusEffectDisabledModifier())
|
||||
.onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in
|
||||
if !isFocused {
|
||||
appChromeModel.validateAndSaveNickname()
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
appChromeModel.validateAndSaveNickname()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let countAndColor = channelPeopleCountAndColor()
|
||||
let headerCountColor = countAndColor.1
|
||||
let headerOtherPeersCount: Int = {
|
||||
if case .location = locationChannelsModel.selectedChannel {
|
||||
return peerListModel.visibleGeohashPeerCount
|
||||
}
|
||||
return countAndColor.0
|
||||
}()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if appChromeModel.hasUnreadPrivateMessages {
|
||||
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
||||
)
|
||||
}
|
||||
|
||||
if case .mesh = locationChannelsModel.selectedChannel,
|
||||
locationChannelsModel.permissionState == .authorized {
|
||||
Button(action: {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
notesGeohash = locationChannelsModel.currentBuildingGeohash
|
||||
showLocationNotes = true
|
||||
}) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: "note.text")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(Color.orange.opacity(0.8))
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
||||
)
|
||||
}
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
|
||||
let badgeText: String = {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh: return "#mesh"
|
||||
case .location(let channel): return "#\(channel.geohash)"
|
||||
}
|
||||
}()
|
||||
let badgeColor: Color = {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
case .location:
|
||||
return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
}()
|
||||
|
||||
Text(badgeText)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(badgeColor)
|
||||
.lineLimit(headerLineLimit)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 2)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: headerPeerIconSize, weight: .regular))
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
||||
locale: .current,
|
||||
headerOtherPeersCount
|
||||
)
|
||||
)
|
||||
Text("\(headerOtherPeersCount)")
|
||||
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(headerCountColor)
|
||||
.padding(.leading, 2)
|
||||
.lineLimit(headerLineLimit)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar.toggle()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
VerificationSheetView(isPresented: $showVerifySheet)
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
Group {
|
||||
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
|
||||
LocationNotesView(
|
||||
geohash: geohash,
|
||||
senderNickname: appChromeModel.nickname
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
} else {
|
||||
ContentLocationNotesUnavailableView(
|
||||
showLocationNotes: $showLocationNotes,
|
||||
headerHeight: headerHeight,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
locationChannelsModel.enableLocationChannels()
|
||||
locationChannelsModel.beginLiveRefresh()
|
||||
}
|
||||
.onDisappear {
|
||||
locationChannelsModel.endLiveRefresh()
|
||||
}
|
||||
.onChange(of: locationChannelsModel.availableChannels) { channels in
|
||||
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
||||
notesGeohash != current {
|
||||
notesGeohash = current
|
||||
#if os(iOS)
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.prepare()
|
||||
generator.impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
}
|
||||
.onChange(of: locationChannelsModel.selectedChannel) { _ in
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
}
|
||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
}
|
||||
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("content.alert.screenshot.message")
|
||||
}
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
}
|
||||
|
||||
private extension ContentHeaderView {
|
||||
var headerLineLimit: Int? {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
let count = peerListModel.visibleGeohashPeerCount
|
||||
let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
return (count, count > 0 ? standardGreen : Color.secondary)
|
||||
case .mesh:
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
|
||||
return (peerListModel.reachableMeshPeerCount, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentLocationNotesUnavailableView: View {
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
|
||||
@Binding var showLocationNotes: Bool
|
||||
|
||||
let headerHeight: CGFloat
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("content.notes.title")
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
Spacer()
|
||||
Button(action: { showLocationNotes = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
Text("content.notes.location_unavailable")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
Button("content.location.enable") {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct ContentPeopleSheetView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var messageText: String
|
||||
@Binding var selectedMessageSender: String?
|
||||
@Binding var selectedMessageSenderID: PeerID?
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@Binding var windowCountPublic: Int
|
||||
@Binding var windowCountPrivate: [PeerID: Int]
|
||||
@Binding var isAtBottomPrivate: Bool
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
||||
@Binding var autocompleteDebounceTimer: Timer?
|
||||
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let headerHeight: CGFloat
|
||||
let onSendMessage: () -> Void
|
||||
|
||||
#if os(iOS)
|
||||
@Binding var showImagePicker: Bool
|
||||
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
|
||||
#else
|
||||
@Binding var showMacImagePicker: Bool
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if privateConversationModel.selectedPeerID != nil {
|
||||
#if os(iOS)
|
||||
ContentPrivateChatSheetView(
|
||||
showSidebar: $showSidebar,
|
||||
messageText: $messageText,
|
||||
selectedMessageSender: $selectedMessageSender,
|
||||
selectedMessageSenderID: $selectedMessageSenderID,
|
||||
imagePreviewURL: $imagePreviewURL,
|
||||
windowCountPublic: $windowCountPublic,
|
||||
windowCountPrivate: $windowCountPrivate,
|
||||
isAtBottomPrivate: $isAtBottomPrivate,
|
||||
isTextFieldFocused: isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
headerHeight: headerHeight,
|
||||
onSendMessage: onSendMessage,
|
||||
showImagePicker: $showImagePicker,
|
||||
imagePickerSourceType: $imagePickerSourceType
|
||||
)
|
||||
#else
|
||||
ContentPrivateChatSheetView(
|
||||
showSidebar: $showSidebar,
|
||||
messageText: $messageText,
|
||||
selectedMessageSender: $selectedMessageSender,
|
||||
selectedMessageSenderID: $selectedMessageSenderID,
|
||||
imagePreviewURL: $imagePreviewURL,
|
||||
windowCountPublic: $windowCountPublic,
|
||||
windowCountPrivate: $windowCountPrivate,
|
||||
isAtBottomPrivate: $isAtBottomPrivate,
|
||||
isTextFieldFocused: isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
headerHeight: headerHeight,
|
||||
onSendMessage: onSendMessage,
|
||||
showMacImagePicker: $showMacImagePicker
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
ContentPeopleListView(
|
||||
showSidebar: $showSidebar,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
headerHeight: headerHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
appChromeModel.clearFingerprint()
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if let peerID = appChromeModel.showingFingerprintFor {
|
||||
FingerprintView(peerID: peerID)
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||
showImagePicker = false
|
||||
conversationUIModel.processSelectedImage(image)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showMacImagePicker) {
|
||||
MacImagePickerView { url in
|
||||
showMacImagePicker = false
|
||||
conversationUIModel.processSelectedImage(from: url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPeopleListView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let headerHeight: CGFloat
|
||||
|
||||
@State private var showVerifySheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(peopleSheetTitle)
|
||||
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
if case .mesh = locationChannelsModel.selectedChannel {
|
||||
Button(action: { showVerifySheet = true }) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
dismiss()
|
||||
showSidebar = false
|
||||
showVerifySheet = false
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
let activeText = String.localizedStringWithFormat(
|
||||
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
|
||||
"\(peopleSheetActiveCount)"
|
||||
)
|
||||
|
||||
if let subtitle = peopleSheetSubtitle {
|
||||
let subtitleColor: Color = {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return Color.blue
|
||||
case .location:
|
||||
return Color.green
|
||||
}
|
||||
}()
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(subtitle)
|
||||
.foregroundColor(subtitleColor)
|
||||
Text(activeText)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
} else {
|
||||
Text(activeText)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.background(backgroundColor)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if case .location = locationChannelsModel.selectedChannel {
|
||||
GeohashPeopleList(
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPerson: {
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MeshPeerList(
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPeer: { peerID in
|
||||
peerListModel.startConversation(with: peerID)
|
||||
showSidebar = true
|
||||
},
|
||||
onToggleFavorite: { peerID in
|
||||
peerListModel.toggleFavorite(peerID: peerID)
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
appChromeModel.showFingerprint(for: peerID)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.id(peerListModel.renderID)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
VerificationSheetView(isPresented: $showVerifySheet)
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ContentPeopleListView {
|
||||
var peopleSheetTitle: String {
|
||||
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
|
||||
}
|
||||
|
||||
var peopleSheetSubtitle: String? {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .location(let channel):
|
||||
return "#\(channel.geohash.lowercased())"
|
||||
}
|
||||
}
|
||||
|
||||
var peopleSheetActiveCount: Int {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return peerListModel.reachableMeshPeerCount
|
||||
case .location:
|
||||
return peerListModel.visibleGeohashPeerCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrivateChatSheetView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var messageText: String
|
||||
@Binding var selectedMessageSender: String?
|
||||
@Binding var selectedMessageSenderID: PeerID?
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@Binding var windowCountPublic: Int
|
||||
@Binding var windowCountPrivate: [PeerID: Int]
|
||||
@Binding var isAtBottomPrivate: Bool
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
||||
@Binding var autocompleteDebounceTimer: Timer?
|
||||
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let headerHeight: CGFloat
|
||||
let onSendMessage: () -> Void
|
||||
|
||||
#if os(iOS)
|
||||
@Binding var showImagePicker: Bool
|
||||
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
|
||||
#else
|
||||
@Binding var showMacImagePicker: Bool
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let headerState = privateConversationModel.selectedHeaderState {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ContentPrivateHeaderInfoButton(
|
||||
headerState: headerState,
|
||||
headerHeight: headerHeight,
|
||||
textColor: textColor
|
||||
)
|
||||
|
||||
if headerState.supportsFavoriteToggle {
|
||||
Button(action: {
|
||||
privateConversationModel.toggleFavoriteForSelectedConversation()
|
||||
}) {
|
||||
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
headerState.isFavorite
|
||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
privateConversationModel.endConversation()
|
||||
showSidebar = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
MessageListView(
|
||||
privatePeer: privateConversationModel.selectedPeerID,
|
||||
isAtBottom: $isAtBottomPrivate,
|
||||
messageText: $messageText,
|
||||
selectedMessageSender: $selectedMessageSender,
|
||||
selectedMessageSenderID: $selectedMessageSenderID,
|
||||
imagePreviewURL: $imagePreviewURL,
|
||||
windowCountPublic: $windowCountPublic,
|
||||
windowCountPrivate: $windowCountPrivate,
|
||||
showSidebar: $showSidebar,
|
||||
isTextFieldFocused: isTextFieldFocused
|
||||
)
|
||||
.background(backgroundColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
#if os(iOS)
|
||||
ContentComposerView(
|
||||
messageText: $messageText,
|
||||
isTextFieldFocused: isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onSendMessage: onSendMessage,
|
||||
showImagePicker: $showImagePicker,
|
||||
imagePickerSourceType: $imagePickerSourceType
|
||||
)
|
||||
#else
|
||||
ContentComposerView(
|
||||
messageText: $messageText,
|
||||
isTextFieldFocused: isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onSendMessage: onSendMessage,
|
||||
showMacImagePicker: $showMacImagePicker
|
||||
)
|
||||
#endif
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
.highPriorityGesture(
|
||||
DragGesture(minimumDistance: 25, coordinateSpace: .local)
|
||||
.onEnded { value in
|
||||
let horizontal = value.translation.width
|
||||
let vertical = abs(value.translation.height)
|
||||
guard horizontal > 80, vertical < 60 else { return }
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrivateHeaderInfoButton: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
|
||||
let headerState: PrivateConversationHeaderState
|
||||
let headerHeight: CGFloat
|
||||
let textColor: Color
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
case .meshReachable:
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
if let encryptionStatus = headerState.encryptionStatus,
|
||||
let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(
|
||||
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
|
||||
? textColor
|
||||
: Color.red
|
||||
)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
||||
locale: .current,
|
||||
encryptionStatus.accessibilityDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
||||
locale: .current,
|
||||
headerState.displayName
|
||||
)
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(height: headerHeight)
|
||||
}
|
||||
}
|
||||
+136
-1047
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user