mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa45ee3aea | ||
|
|
e8fb4a6333 |
@@ -21,6 +21,10 @@ jobs:
|
|||||||
path: localPackages/BitLogger
|
path: localPackages/BitLogger
|
||||||
- name: BitFoundation
|
- name: BitFoundation
|
||||||
path: localPackages/BitFoundation
|
path: localPackages/BitFoundation
|
||||||
|
- name: Noise
|
||||||
|
path: localPackages/Noise
|
||||||
|
- name: Nostr
|
||||||
|
path: localPackages/Nostr
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
+7
-3
@@ -17,8 +17,10 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.package(path: "localPackages/Noise"),
|
||||||
.package(path: "localPackages/BitFoundation"),
|
.package(path: "localPackages/BitFoundation"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
|
.package(path: "localPackages/Nostr"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -28,6 +30,8 @@ let package = Package(
|
|||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
|
.product(name: "Noise", package: "Noise"),
|
||||||
|
.product(name: "Nostr", package: "Nostr"),
|
||||||
.product(name: "Tor", package: "Arti")
|
.product(name: "Tor", package: "Arti")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
@@ -48,7 +52,8 @@ let package = Package(
|
|||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"bitchat",
|
"bitchat",
|
||||||
.product(name: "BitFoundation", package: "BitFoundation")
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
|
.product(name: "Nostr", package: "Nostr")
|
||||||
],
|
],
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -56,8 +61,7 @@ let package = Package(
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+40
@@ -10,6 +10,10 @@
|
|||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
|
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
|
||||||
|
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
|
||||||
|
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
|
||||||
|
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
|
||||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
@@ -156,7 +160,9 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
|
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -165,7 +171,9 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
|
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -228,6 +236,8 @@
|
|||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||||
|
A63180842F8103B600B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -309,6 +319,8 @@
|
|||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */,
|
||||||
|
A63180822F8103AB00B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -351,6 +363,8 @@
|
|||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||||
|
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -916,6 +930,14 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Noise;
|
||||||
|
};
|
||||||
|
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Nostr;
|
||||||
|
};
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitFoundation;
|
relativePath = localPackages/BitFoundation;
|
||||||
@@ -947,6 +969,24 @@
|
|||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
productName = P256K;
|
productName = P256K;
|
||||||
};
|
};
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Noise;
|
||||||
|
};
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
|
||||||
|
productName = Noise;
|
||||||
|
};
|
||||||
|
A63180822F8103AB00B8B128 /* Nostr */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Nostr;
|
||||||
|
};
|
||||||
|
A63180842F8103B600B8B128 /* Nostr */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
|
||||||
|
productName = Nostr;
|
||||||
|
};
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitFoundation;
|
productName = BitFoundation;
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+192
-31
@@ -6,7 +6,10 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import BitFoundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -14,47 +17,120 @@ struct BitchatApp: App {
|
|||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var runtime: AppRuntime
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
// Skip the very first .active-triggered Tor restart on cold launch
|
||||||
|
@State private var didHandleInitialActive: Bool = false
|
||||||
|
@State private var didEnterBackground: Bool = false
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
GeoRelayDirectory.setupShared(dependencies: .live())
|
||||||
|
NostrRelayManager.setupShared(dependencies: .live())
|
||||||
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
|
_chatViewModel = StateObject(
|
||||||
|
wrappedValue: ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(runtime.publicChatModel)
|
.environmentObject(chatViewModel)
|
||||||
.environmentObject(runtime.privateInboxModel)
|
|
||||||
.environmentObject(runtime.privateConversationModel)
|
|
||||||
.environmentObject(runtime.verificationModel)
|
|
||||||
.environmentObject(runtime.conversationUIModel)
|
|
||||||
.environmentObject(runtime.locationChannelsModel)
|
|
||||||
.environmentObject(runtime.peerListModel)
|
|
||||||
.environmentObject(runtime.appChromeModel)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||||
runtime.start()
|
// 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()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
runtime.handleScenePhaseChange(newPhase)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
runtime.handleDidBecomeActiveNotification()
|
// Check for shared content when app becomes active
|
||||||
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
runtime.handleMacDidBecomeActiveNotification()
|
// App became active
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -63,18 +139,66 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleURL(_ url: URL) {
|
||||||
|
if url.scheme == "bitchat" && url.host == "share" {
|
||||||
|
// Handle shared content
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkForSharedContent() {
|
||||||
|
// Check app group for shared content from extension
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||||
|
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if shared within configured window
|
||||||
|
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||||
|
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||||
|
|
||||||
|
// Clear the shared content
|
||||||
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
|
// No need to force synchronize here
|
||||||
|
|
||||||
|
// Send the shared content immediately on the main queue
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if contentType == "url" {
|
||||||
|
// Try to parse as JSON first
|
||||||
|
if let data = sharedContent.data(using: .utf8),
|
||||||
|
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||||
|
let url = urlData["url"] {
|
||||||
|
// Send plain URL
|
||||||
|
self.chatViewModel.sendMessage(url)
|
||||||
|
} else {
|
||||||
|
// Fallback to simple URL
|
||||||
|
self.chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
runtime?.applicationWillTerminate()
|
chatViewModel?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -83,29 +207,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
runtime?.applicationWillTerminate()
|
chatViewModel?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
Task { @MainActor in
|
// Check if this is a private message notification
|
||||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Handle deeplink (e.g., geohash activity)
|
||||||
|
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||||
|
#if os(iOS)
|
||||||
|
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||||
|
#else
|
||||||
|
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +252,34 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
Task {
|
// Check if this is a private message notification
|
||||||
let options = await self.runtime?.presentationOptions(
|
if identifier.hasPrefix("private-") {
|
||||||
forNotificationIdentifier: identifier,
|
// Get peer ID from userInfo
|
||||||
userInfo: userInfo
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
) ?? [.banner, .sound]
|
// Don't show notification if the private chat is already open
|
||||||
completionHandler(options)
|
// Access main-actor-isolated property via Task
|
||||||
|
Task { @MainActor in
|
||||||
|
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||||
|
completionHandler([])
|
||||||
|
} else {
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deep = userInfo["deeplink"] as? String,
|
||||||
|
let gh = deep.components(separatedBy: "/").last {
|
||||||
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification in all other cases
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,39 +15,30 @@ enum ImageUtilsError: Error {
|
|||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
try validateImageSource(at: 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
|
||||||
|
}
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -73,7 +64,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -115,7 +106,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -151,7 +142,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -195,17 +186,12 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
private static func makeOutputURL() throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||||
|
|
||||||
let directory: URL
|
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
if let outputDirectory {
|
|
||||||
directory = outputDirectory
|
|
||||||
} else {
|
|
||||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
|
||||||
}
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,12 +202,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
cache = IdentityCache()
|
// Log error but continue with empty cache
|
||||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||||
SecureLogger.warning(
|
|
||||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
|
||||||
category: .security
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension BitchatMessage {
|
extension BitchatMessage {
|
||||||
|
|||||||
+20
-25
@@ -6,39 +6,34 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import class Foundation.DateFormatter
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
import struct Foundation.AttributedString
|
|
||||||
import struct Foundation.Data
|
|
||||||
import struct Foundation.Date
|
|
||||||
import struct Foundation.TimeInterval
|
|
||||||
import struct Foundation.UUID
|
|
||||||
|
|
||||||
/// Represents a user-visible message in the BitChat system.
|
/// Represents a user-visible message in the BitChat system.
|
||||||
/// Handles both broadcast messages and private encrypted messages,
|
/// Handles both broadcast messages and private encrypted messages,
|
||||||
/// with support for mentions, replies, and delivery tracking.
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
/// - Note: This is the primary data model for chat messages
|
/// - Note: This is the primary data model for chat messages
|
||||||
public final class BitchatMessage: Codable {
|
final class BitchatMessage: Codable {
|
||||||
public let id: String
|
let id: String
|
||||||
public let sender: String
|
let sender: String
|
||||||
public let content: String
|
let content: String
|
||||||
public let timestamp: Date
|
let timestamp: Date
|
||||||
public let isRelay: Bool
|
let isRelay: Bool
|
||||||
public let originalSender: String?
|
let originalSender: String?
|
||||||
public let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
public let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
public let senderPeerID: PeerID?
|
let senderPeerID: PeerID?
|
||||||
public let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
public var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
// Cached formatted text (not included in Codable)
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +43,7 @@ public final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
init(
|
||||||
id: String? = nil,
|
id: String? = nil,
|
||||||
sender: String,
|
sender: String,
|
||||||
content: String,
|
content: String,
|
||||||
@@ -78,7 +73,7 @@ public final class BitchatMessage: Codable {
|
|||||||
// MARK: - Equatable Conformance
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
extension BitchatMessage: Equatable {
|
extension BitchatMessage: Equatable {
|
||||||
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
return lhs.id == rhs.id &&
|
return lhs.id == rhs.id &&
|
||||||
lhs.sender == rhs.sender &&
|
lhs.sender == rhs.sender &&
|
||||||
lhs.content == rhs.content &&
|
lhs.content == rhs.content &&
|
||||||
@@ -336,14 +331,14 @@ extension BitchatMessage {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public var formattedTimestamp: String {
|
var formattedTimestamp: String {
|
||||||
Self.timestampFormatter.string(from: timestamp)
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == BitchatMessage {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
public func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
let arr = filter { $0.content.trimmed.isEmpty == false }
|
||||||
guard arr.count > 1 else {
|
guard arr.count > 1 else {
|
||||||
return arr
|
return arr
|
||||||
+17
-17
@@ -6,26 +6,26 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
import BitFoundation
|
||||||
|
|
||||||
/// The core packet structure for all BitChat protocol messages.
|
/// The core packet structure for all BitChat protocol messages.
|
||||||
/// Encapsulates all data needed for routing through the mesh network,
|
/// Encapsulates all data needed for routing through the mesh network,
|
||||||
/// including TTL for hop limiting and optional encryption.
|
/// including TTL for hop limiting and optional encryption.
|
||||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
public struct BitchatPacket: Codable {
|
struct BitchatPacket: Codable {
|
||||||
let version: UInt8
|
let version: UInt8
|
||||||
public let type: UInt8
|
let type: UInt8
|
||||||
public let senderID: Data
|
let senderID: Data
|
||||||
public let recipientID: Data?
|
let recipientID: Data?
|
||||||
public let timestamp: UInt64
|
let timestamp: UInt64
|
||||||
public let payload: Data
|
let payload: Data
|
||||||
public var signature: Data?
|
var signature: Data?
|
||||||
public var ttl: UInt8
|
var ttl: UInt8
|
||||||
public var route: [Data]?
|
var route: [Data]?
|
||||||
public var isRSR: Bool
|
var isRSR: Bool
|
||||||
|
|
||||||
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -66,18 +66,18 @@ public struct BitchatPacket: Codable {
|
|||||||
BinaryProtocol.encode(self)
|
BinaryProtocol.encode(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func toBinaryData(padding: Bool = true) -> Data? {
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
BinaryProtocol.encode(self, padding: padding)
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible helper (defaults to padded encoding)
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
public func toBinaryData() -> Data? {
|
func toBinaryData() -> Data? {
|
||||||
toBinaryData(padding: true)
|
toBinaryData(padding: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create binary representation for signing (without signature and TTL fields)
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
/// TTL is excluded because it changes during packet relay operations
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
public func toBinaryDataForSigning() -> Data? {
|
func toBinaryDataForSigning() -> Data? {
|
||||||
// Create a copy without signature and with fixed TTL for signing
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
// TTL must be excluded because it changes during relay
|
// TTL must be excluded because it changes during relay
|
||||||
let unsignedPacket = BitchatPacket(
|
let unsignedPacket = BitchatPacket(
|
||||||
@@ -95,7 +95,7 @@ public struct BitchatPacket: Codable {
|
|||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func from(_ data: Data) -> BitchatPacket? {
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
BinaryProtocol.decode(data)
|
BinaryProtocol.decode(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
|
|
||||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Nostr
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - GeoRelayDirectory Live Dependencies
|
||||||
|
|
||||||
|
extension GeoRelayDirectoryDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
#if os(iOS)
|
||||||
|
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||||
|
#elseif os(macOS)
|
||||||
|
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||||
|
#else
|
||||||
|
let activeNotificationName: Notification.Name? = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return Self(
|
||||||
|
userDefaults: .standard,
|
||||||
|
notificationCenter: .default,
|
||||||
|
now: Date.init,
|
||||||
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||||
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||||
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
|
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||||
|
makeFetchData: {
|
||||||
|
let session = TorURLSession.shared.session
|
||||||
|
return { request in
|
||||||
|
let (data, _) = try await session.data(for: request)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readData: { try? Data(contentsOf: $0) },
|
||||||
|
writeData: { data, url in
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
},
|
||||||
|
cacheURL: {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("georelays_cache.csv")
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bundledCSVURLs: {
|
||||||
|
[
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
},
|
||||||
|
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||||
|
retrySleep: { delay in
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
},
|
||||||
|
torReadyNotificationName: .TorDidBecomeReady,
|
||||||
|
activeNotificationName: activeNotificationName,
|
||||||
|
autoStart: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NostrRelayManager Live Dependencies
|
||||||
|
|
||||||
|
extension NostrRelayManagerDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
Self(
|
||||||
|
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||||
|
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||||
|
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||||
|
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||||
|
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||||
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
|
||||||
|
.map { state -> LocationPermissionState in
|
||||||
|
switch state {
|
||||||
|
case .notDetermined:.notDetermined
|
||||||
|
case .authorized: .authorized
|
||||||
|
case .denied: .denied
|
||||||
|
case .restricted: .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher(),
|
||||||
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
|
torIsReady: { TorManager.shared.isReady },
|
||||||
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
|
awaitTorReady: { completion in
|
||||||
|
Task.detached {
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
completion(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
|
},
|
||||||
|
now: Date.init
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-13
@@ -5,15 +5,15 @@
|
|||||||
// Binary encoding utilities for efficient protocol messages
|
// Binary encoding utilities for efficient protocol messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - Binary Encoding Utilities
|
// MARK: - Binary Encoding Utilities
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
@inlinable public mutating func appendUInt8(_ value: UInt8) {
|
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendString(_ string: String, maxLength: Int = 255) {
|
mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||||
guard let data = string.data(using: .utf8) else { return }
|
guard let data = string.data(using: .utf8) else { return }
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -60,12 +60,12 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendDate(_ date: Date) {
|
mutating func appendDate(_ date: Date) {
|
||||||
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
||||||
self.appendUInt64(timestamp)
|
self.appendUInt64(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendUUID(_ uuid: String) {
|
mutating func appendUUID(_ uuid: String) {
|
||||||
// Convert UUID string to 16 bytes
|
// Convert UUID string to 16 bytes
|
||||||
var uuidData = Data(count: 16)
|
var uuidData = Data(count: 16)
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
|
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||||
guard offset >= 0 && offset < self.count else { return nil }
|
guard offset >= 0 && offset < self.count else { return nil }
|
||||||
let value = self[offset]
|
let value = self[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
@@ -120,7 +120,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -139,7 +139,7 @@ extension Data {
|
|||||||
return String(data: stringData, encoding: .utf8)
|
return String(data: stringData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -158,12 +158,12 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readDate(at offset: inout Int) -> Date? {
|
func readDate(at offset: inout Int) -> Date? {
|
||||||
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
||||||
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readUUID(at offset: inout Int) -> String? {
|
func readUUID(at offset: inout Int) -> String? {
|
||||||
guard offset + 16 <= self.count else { return nil }
|
guard offset + 16 <= self.count else { return nil }
|
||||||
|
|
||||||
let uuidData = self[offset..<offset + 16]
|
let uuidData = self[offset..<offset + 16]
|
||||||
@@ -184,7 +184,7 @@ extension Data {
|
|||||||
return result.uppercased()
|
return result.uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||||
guard offset + count <= self.count else { return nil }
|
guard offset + count <= self.count else { return nil }
|
||||||
|
|
||||||
let data = self[offset..<offset + count]
|
let data = self[offset..<offset + count]
|
||||||
+26
-17
@@ -88,31 +88,40 @@
|
|||||||
/// - Platform-optimized byte swapping
|
/// - Platform-optimized byte swapping
|
||||||
///
|
///
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import class Foundation.NSData
|
import BitLogger
|
||||||
private import BitLogger
|
|
||||||
|
extension Data {
|
||||||
|
func trimmingNullBytes() -> Data {
|
||||||
|
// Find the first null byte
|
||||||
|
if let nullIndex = self.firstIndex(of: 0) {
|
||||||
|
return self.prefix(nullIndex)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||||
/// Provides static methods for converting between BitchatPacket objects and
|
/// Provides static methods for converting between BitchatPacket objects and
|
||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
public struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
public static let v1HeaderSize = 14
|
static let v1HeaderSize = 14
|
||||||
static let v2HeaderSize = 16
|
static let v2HeaderSize = 16
|
||||||
public static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
public static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
public static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
// Field offsets within packet header
|
// Field offsets within packet header
|
||||||
public struct Offsets {
|
struct Offsets {
|
||||||
static let version = 0
|
static let version = 0
|
||||||
static let type = 1
|
static let type = 1
|
||||||
static let ttl = 2
|
static let ttl = 2
|
||||||
static let timestamp = 3
|
static let timestamp = 3
|
||||||
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func headerSize(for version: UInt8) -> Int? {
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
switch version {
|
switch version {
|
||||||
case 1: return v1HeaderSize
|
case 1: return v1HeaderSize
|
||||||
case 2: return v2HeaderSize
|
case 2: return v2HeaderSize
|
||||||
@@ -124,11 +133,11 @@ public struct BinaryProtocol {
|
|||||||
return version == 2 ? 4 : 2
|
return version == 2 ? 4 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Flags {
|
struct Flags {
|
||||||
public static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
public static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
public static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
public static let hasRoute: UInt8 = 0x08
|
static let hasRoute: UInt8 = 0x08
|
||||||
static let isRSR: UInt8 = 0x10
|
static let isRSR: UInt8 = 0x10
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +266,7 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
public static func decode(_ data: Data) -> BitchatPacket? {
|
static func decode(_ data: Data) -> BitchatPacket? {
|
||||||
// Try decode as-is first (robust when padding wasn't applied)
|
// Try decode as-is first (robust when padding wasn't applied)
|
||||||
if let pkt = decodeCore(data) { return pkt }
|
if let pkt = decodeCore(data) { return pkt }
|
||||||
// If that fails, try after removing padding
|
// If that fails, try after removing padding
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
|||||||
@@ -62,6 +62,40 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
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
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
/// Types of payloads embedded within noiseEncrypted messages.
|
/// Types of payloads embedded within noiseEncrypted messages.
|
||||||
@@ -98,6 +132,35 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
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
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2109
-1084
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
@@ -28,7 +29,6 @@ struct CommandGeoParticipant {
|
|||||||
protocol CommandContextProvider: AnyObject {
|
protocol CommandContextProvider: AnyObject {
|
||||||
// MARK: - State Properties
|
// MARK: - State Properties
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var activeChannel: ChannelID { get }
|
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var blockedUsers: Set<String> { get }
|
var blockedUsers: Set<String> { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
@@ -76,7 +76,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||||
let inGeoPublic: Bool = {
|
let inGeoPublic: Bool = {
|
||||||
switch contextProvider?.activeChannel ?? .mesh {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
case .mesh: return false
|
case .mesh: return false
|
||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleWho() -> CommandResult {
|
private func handleWho() -> CommandResult {
|
||||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||||
switch contextProvider?.activeChannel ?? .mesh {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
@@ -74,7 +75,7 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let idBridge = NostrIdentityBridge()
|
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
|
|||||||
@@ -7,10 +7,78 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import protocol Nostr.NostrKeychainStoring
|
||||||
|
import protocol Noise.SecureMemoryCleaner
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
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: SecureMemoryCleaner, NostrKeychainStoring {
|
||||||
|
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 {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
|
|||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
private static let idBridge = NostrIdentityBridge()
|
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
|
|||||||
@@ -255,8 +255,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content, use plain formatting to avoid expensive regex and detector work.
|
// For very long content without special tokens, use plain formatting
|
||||||
if content.isOversizedForRichFormatting() {
|
let containsCashu = containsCashuToken(content)
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@@ -106,7 +107,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
weak var eventDelegate: TransportEventDelegate?
|
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
@@ -174,9 +174,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
// Use barrier to synchronize access to readQueue
|
// Use barrier to synchronize access to readQueue
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
self.processReadQueueIfNeeded()
|
self?.processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
struct NotificationStreamAssembler {
|
||||||
@@ -21,19 +20,6 @@ struct NotificationStreamAssembler {
|
|||||||
pendingFrameExpectedLength = 0
|
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) {
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
@@ -55,9 +41,6 @@ struct NotificationStreamAssembler {
|
|||||||
while buffer.count >= minimumFramePrefix {
|
while buffer.count >= minimumFramePrefix {
|
||||||
guard let version = buffer.first else { break }
|
guard let version = buffer.first else { break }
|
||||||
guard version == 1 || version == 2 else {
|
guard version == 1 || version == 2 else {
|
||||||
if discardLeadingPaddingIfPresent() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
pendingFrameStartedAt = nil
|
pendingFrameStartedAt = nil
|
||||||
pendingFrameExpectedLength = 0
|
pendingFrameExpectedLength = 0
|
||||||
@@ -149,11 +132,6 @@ struct NotificationStreamAssembler {
|
|||||||
let frame = Data(buffer.prefix(frameLength))
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
buffer.removeFirst(frameLength)
|
buffer.removeFirst(frameLength)
|
||||||
_ = discardLeadingPaddingIfPresent()
|
|
||||||
}
|
|
||||||
|
|
||||||
if discardLeadingPaddingIfPresent() {
|
|
||||||
return (frames, dropped, didReset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct RelayDecision {
|
|||||||
struct RelayController {
|
struct RelayController {
|
||||||
static func decide(ttl: UInt8,
|
static func decide(ttl: UInt8,
|
||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
recipientIsSelf: Bool = false,
|
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
isFragment: Bool,
|
||||||
@@ -23,7 +22,7 @@ struct RelayController {
|
|||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
if ttlCap <= 1 || senderIsSelf {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import CoreBluetooth
|
|
||||||
|
|
||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
@@ -13,27 +12,9 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
let lastSeen: Date
|
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 {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
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)
|
// Peer events (preferred over publishers for UI)
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
@@ -103,36 +84,4 @@ protocol TransportPeerEventsDelegate: AnyObject {
|
|||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@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 {}
|
extension BLEService: Transport {}
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ enum TransportConfig {
|
|||||||
static let nostrShortKeyDisplayLength: Int = 8
|
static let nostrShortKeyDisplayLength: Int = 8
|
||||||
static let nostrConvKeyPrefixLength: Int = 16
|
static let nostrConvKeyPrefixLength: Int = 16
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
static let compressionThresholdBytes: Int = 100
|
||||||
|
|
||||||
// Message deduplication
|
// Message deduplication
|
||||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||||
static let messageDedupMaxCount: Int = 1000
|
static let messageDedupMaxCount: Int = 1000
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -128,15 +128,17 @@ final class GossipSyncManager {
|
|||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
guard let self = self else { return }
|
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 {
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fragment)
|
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fileTransfer)
|
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.sendRequestSync(to: peerID, types: types)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,18 +393,13 @@ final class GossipSyncManager {
|
|||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||||
|
|
||||||
var dueTypes: SyncTypeFlags = []
|
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
dueTypes.formUnion(syncSchedules[index].types)
|
sendPeriodicSync(for: syncSchedules[index].types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dueTypes.isEmpty {
|
|
||||||
sendPeriodicSync(for: dueTypes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import struct BitFoundation.BitchatPacket
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||||
|
|||||||
+3
-3
@@ -6,12 +6,12 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
private import Compression
|
import Compression
|
||||||
|
|
||||||
struct CompressionUtil {
|
struct CompressionUtil {
|
||||||
// Compression threshold - don't compress if data is smaller than this
|
// Compression threshold - don't compress if data is smaller than this
|
||||||
static let compressionThreshold = Constants.compressionThresholdBytes // bytes
|
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
||||||
|
|
||||||
// Compress data using zlib algorithm (most compatible)
|
// Compress data using zlib algorithm (most compatible)
|
||||||
static func compress(_ data: Data) -> Data? {
|
static func compress(_ data: Data) -> Data? {
|
||||||
+8
-6
@@ -1,13 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||||
public enum FileTransferLimits {
|
enum FileTransferLimits {
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
public static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
public static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||||
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||||
@@ -17,7 +19,7 @@ public enum FileTransferLimits {
|
|||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static func isValidPayload(_ size: Int) -> Bool {
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= maxPayloadBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
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
|
|
||||||
if content.isOversizedForRichFormatting() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,858 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
final class ChatNostrCoordinator {
|
|
||||||
private unowned let viewModel: ChatViewModel
|
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func resubscribeCurrentGeohash() {
|
|
||||||
guard case .location(let channel) = viewModel.activeChannel else { return }
|
|
||||||
guard let subID = viewModel.geoSubscriptionID else {
|
|
||||||
switchLocationChannel(to: viewModel.activeChannel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.participantTracker.startRefreshTimer()
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
channel.geohash,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashInitialLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: channel.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.subscribeNostrEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
|
||||||
viewModel.geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
|
||||||
viewModel.geoDmSubscriptionID = dmSub
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.subscribeGiftWrap(giftWrap, id: identity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
|
||||||
guard event.isValidSignature() else { return }
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
|
||||||
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
if let gh = viewModel.currentGeohash,
|
|
||||||
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
let nick = nickTag[1].trimmed
|
|
||||||
viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasTeleportTag = event.tags.contains { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasTeleportTag {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = viewModel.currentGeohash,
|
|
||||||
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return myIdentity.publicKeyHex.lowercased() == key
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
if !isSelf {
|
|
||||||
Task { @MainActor [weak viewModel] in
|
|
||||||
viewModel?.locationPresenceStore.markTeleported(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content.trimmed
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let timestamp = min(rawTs, Date())
|
|
||||||
let mentions = viewModel.parseMentions(from: content)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
|
||||||
guard let viewModel else { return }
|
|
||||||
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
|
||||||
viewModel.handlePublicMessage(message)
|
|
||||||
if !isBlocked {
|
|
||||||
viewModel.checkForMentions(message)
|
|
||||||
viewModel.sendHapticFeedback(for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: id
|
|
||||||
),
|
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let noisePayload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch noisePayload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
viewModel.handlePrivateMessage(
|
|
||||||
noisePayload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
convKey: convKey,
|
|
||||||
id: id,
|
|
||||||
messageTimestamp: messageTimestamp
|
|
||||||
)
|
|
||||||
case .delivered:
|
|
||||||
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
|
||||||
viewModel.publicMessagePipeline.reset()
|
|
||||||
viewModel.activeChannel = channel
|
|
||||||
viewModel.publicMessagePipeline.updateActiveChannel(channel)
|
|
||||||
|
|
||||||
viewModel.deduplicationService.clearNostrCaches()
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
viewModel.refreshVisibleMessages(from: .mesh)
|
|
||||||
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count
|
|
||||||
if emptyMesh > 0 {
|
|
||||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
|
||||||
}
|
|
||||||
viewModel.participantTracker.stopRefreshTimer()
|
|
||||||
viewModel.participantTracker.setActiveGeohash(nil)
|
|
||||||
viewModel.locationPresenceStore.clearTeleportedGeo()
|
|
||||||
|
|
||||||
case .location:
|
|
||||||
viewModel.refreshVisibleMessages(from: channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if case .location = channel {
|
|
||||||
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() {
|
|
||||||
viewModel.addPublicSystemMessage(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sub = viewModel.geoSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
|
||||||
viewModel.geoSubscriptionID = nil
|
|
||||||
}
|
|
||||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
|
||||||
viewModel.geoDmSubscriptionID = nil
|
|
||||||
}
|
|
||||||
viewModel.currentGeohash = nil
|
|
||||||
viewModel.participantTracker.setActiveGeohash(nil)
|
|
||||||
viewModel.locationPresenceStore.clearGeoNicknames()
|
|
||||||
|
|
||||||
guard case .location(let channel) = channel else { return }
|
|
||||||
viewModel.currentGeohash = channel.geohash
|
|
||||||
viewModel.participantTracker.setActiveGeohash(channel.geohash)
|
|
||||||
|
|
||||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
|
||||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
|
||||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
|
||||||
let key = identity.publicKeyHex.lowercased()
|
|
||||||
if viewModel.locationManager.teleported && hasRegional && !inRegional {
|
|
||||||
viewModel.locationPresenceStore.markTeleported(key)
|
|
||||||
SecureLogger.info(
|
|
||||||
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
viewModel.locationPresenceStore.clearTeleported(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let subID = "geo-\(channel.geohash)"
|
|
||||||
viewModel.geoSubscriptionID = subID
|
|
||||||
viewModel.participantTracker.startRefreshTimer()
|
|
||||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
|
||||||
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.handleNostrEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToGeoChat(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
|
||||||
guard event.isValidSignature() else { return }
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
|
||||||
|
|
||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
|
||||||
|
|
||||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasTeleportTag = event.tags.contains { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
|
||||||
}
|
|
||||||
|
|
||||||
let isSelf: Bool = {
|
|
||||||
if let gh = viewModel.currentGeohash,
|
|
||||||
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
|
||||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
if hasTeleportTag, !isSelf {
|
|
||||||
let key = event.pubkey.lowercased()
|
|
||||||
Task { @MainActor [weak viewModel] in
|
|
||||||
guard let viewModel else { return }
|
|
||||||
viewModel.locationPresenceStore.markTeleported(key)
|
|
||||||
SecureLogger.info(
|
|
||||||
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
if isSelf {
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) < 15 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
||||||
viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
|
||||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
|
||||||
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
|
||||||
let content = event.content
|
|
||||||
|
|
||||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
|
||||||
teleTag.count >= 2,
|
|
||||||
teleTag[1] == "teleport",
|
|
||||||
content.trimmed.isEmpty {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let mentions = viewModel.parseMentions(from: content)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: min(rawTs, Date()),
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
|
||||||
guard let viewModel else { return }
|
|
||||||
viewModel.handlePublicMessage(message)
|
|
||||||
viewModel.checkForMentions(message)
|
|
||||||
viewModel.sendHapticFeedback(for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeToGeoChat(_ channel: GeohashChannel) {
|
|
||||||
guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return }
|
|
||||||
|
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
|
||||||
viewModel.geoDmSubscriptionID = dmSub
|
|
||||||
if TorManager.shared.isReady {
|
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
|
||||||
}
|
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.handleGiftWrap(giftWrap, id: identity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: id
|
|
||||||
) else {
|
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug(
|
|
||||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let payload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
|
||||||
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
viewModel.handlePrivateMessage(
|
|
||||||
payload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
convKey: convKey,
|
|
||||||
id: id,
|
|
||||||
messageTimestamp: messageTimestamp
|
|
||||||
)
|
|
||||||
case .delivered:
|
|
||||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
|
|
||||||
let channel = context.channel
|
|
||||||
let event = context.event
|
|
||||||
let identity = context.identity
|
|
||||||
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
|
||||||
toGeohash: channel.geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if targetRelays.isEmpty {
|
|
||||||
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
|
|
||||||
} else {
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
|
||||||
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
|
||||||
SecureLogger.debug(
|
|
||||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
|
|
||||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
|
||||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
|
||||||
if context.teleported && hasRegional && !inRegional {
|
|
||||||
let key = identity.publicKeyHex.lowercased()
|
|
||||||
viewModel.locationPresenceStore.markTeleported(key)
|
|
||||||
SecureLogger.info(
|
|
||||||
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
|
||||||
if !TorManager.shared.isForeground() {
|
|
||||||
endGeohashSampling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let desired = Set(geohashes)
|
|
||||||
let current = Set(viewModel.geoSamplingSubs.values)
|
|
||||||
let toAdd = desired.subtracting(current)
|
|
||||||
let toRemove = current.subtracting(desired)
|
|
||||||
|
|
||||||
for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
viewModel.geoSamplingSubs.removeValue(forKey: subID)
|
|
||||||
}
|
|
||||||
|
|
||||||
for gh in toAdd {
|
|
||||||
subscribe(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribe(_ gh: String) {
|
|
||||||
let subID = "geo-sample-\(gh)"
|
|
||||||
viewModel.geoSamplingSubs[subID] = gh
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
gh,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
|
||||||
limit: TransportConfig.nostrGeohashSampleLimit
|
|
||||||
)
|
|
||||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.subscribeNostrEvent(event, gh: gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
|
||||||
guard event.isValidSignature() else { return }
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
||||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingCount = viewModel.participantTracker.participantCount(for: gh)
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
|
||||||
|
|
||||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
|
||||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
|
||||||
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
|
||||||
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard existingCount == 0 else { return }
|
|
||||||
|
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
guard UIApplication.shared.applicationState == .active else { return }
|
|
||||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
|
||||||
#elseif os(macOS)
|
|
||||||
guard NSApplication.shared.isActive else { return }
|
|
||||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
cooldownPerGeohash(gh, content: content, event: event)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
|
||||||
let now = Date()
|
|
||||||
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast
|
|
||||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
|
||||||
|
|
||||||
let preview: String = {
|
|
||||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
|
||||||
if content.count <= maxLen { return content }
|
|
||||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
|
||||||
return String(content[..<idx]) + "…"
|
|
||||||
}()
|
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
|
||||||
guard let viewModel else { return }
|
|
||||||
viewModel.lastGeoNotificationAt[gh] = now
|
|
||||||
let senderSuffix = String(event.pubkey.suffix(4))
|
|
||||||
let nick = viewModel.geoNicknames[event.pubkey.lowercased()]
|
|
||||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
|
||||||
|
|
||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
let ts = min(rawTs, Date())
|
|
||||||
let mentions = viewModel.parseMentions(from: content)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: event.id,
|
|
||||||
sender: senderName,
|
|
||||||
content: content,
|
|
||||||
timestamp: ts,
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
|
||||||
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) {
|
|
||||||
viewModel.synchronizePublicConversationStore(forGeohash: gh)
|
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func endGeohashSampling() {
|
|
||||||
for subID in viewModel.geoSamplingSubs.keys {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
|
||||||
}
|
|
||||||
viewModel.geoSamplingSubs.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func setupNostrMessageHandling() {
|
|
||||||
guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug(
|
|
||||||
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.handleNostrMessage(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
|
||||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
|
||||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
await self?.processNostrMessage(giftWrap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
|
||||||
try? viewModel.idBridge.getCurrentNostrIdentity()
|
|
||||||
}
|
|
||||||
guard let currentIdentity else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: currentIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
if content.hasPrefix("verify:") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if content.hasPrefix("bitchat1:") {
|
|
||||||
let packet: BitchatPacket? = await MainActor.run {
|
|
||||||
Self.decodeEmbeddedBitChatPacket(from: content)
|
|
||||||
}
|
|
||||||
guard let packet else {
|
|
||||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let actualSenderNoiseKey: Data? = await MainActor.run {
|
|
||||||
self.findNoiseKey(for: senderPubkey)
|
|
||||||
}
|
|
||||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
|
||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let payload = NoisePayload.decode(packet.payload) {
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey
|
|
||||||
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
viewModel.handlePrivateMessage(
|
|
||||||
payload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
convKey: targetPeerID,
|
|
||||||
id: currentIdentity,
|
|
||||||
messageTimestamp: messageTimestamp
|
|
||||||
)
|
|
||||||
case .delivered:
|
|
||||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .readReceipt:
|
|
||||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
|
||||||
var npubToMatch = nostrPubkey
|
|
||||||
|
|
||||||
if !nostrPubkey.hasPrefix("npub") {
|
|
||||||
if let pubkeyData = Data(hexString: nostrPubkey),
|
|
||||||
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
|
||||||
npubToMatch = encoded
|
|
||||||
} else {
|
|
||||||
SecureLogger.warning(
|
|
||||||
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for relationship in favorites {
|
|
||||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
|
||||||
if storedNostrKey == npubToMatch {
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
|
||||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
|
||||||
return relationship.peerNoisePublicKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug(
|
|
||||||
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendDeliveryAckViaNostrEmbedded(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
wasReadBefore: Bool,
|
|
||||||
senderPubkey: String,
|
|
||||||
key: Data?
|
|
||||||
) {
|
|
||||||
if let _ = key {
|
|
||||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
|
||||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
|
||||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
|
||||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
|
||||||
}
|
|
||||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
|
||||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
|
||||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
|
||||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
|
||||||
SecureLogger.debug(
|
|
||||||
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID {
|
|
||||||
if let _ = key {
|
|
||||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
|
||||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
|
||||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
|
||||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
|
||||||
}
|
|
||||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
|
||||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
|
||||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
|
||||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
|
||||||
SecureLogger.debug(
|
|
||||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
|
||||||
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
|
|
||||||
|
|
||||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
|
||||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
|
||||||
|
|
||||||
if isFavorite {
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var extractedNostrPubkey: String?
|
|
||||||
if let range = content.range(of: "NPUB:") {
|
|
||||||
let suffix = content[range.upperBound...]
|
|
||||||
let parts = suffix.components(separatedBy: "|")
|
|
||||||
if let key = parts.first {
|
|
||||||
extractedNostrPubkey = String(key)
|
|
||||||
}
|
|
||||||
} else if content.contains(":") {
|
|
||||||
let parts = content.components(separatedBy: ":")
|
|
||||||
if parts.count >= 3 {
|
|
||||||
extractedNostrPubkey = String(parts[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
|
||||||
|
|
||||||
if isFavorite && extractedNostrPubkey != nil {
|
|
||||||
SecureLogger.info(
|
|
||||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
peerNostrPublicKey: extractedNostrPubkey,
|
|
||||||
peerNickname: senderNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationService.shared.sendLocalNotification(
|
|
||||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
|
||||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
|
||||||
identifier: "fav-\(UUID().uuidString)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
|
||||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
|
||||||
relationship.peerNostrPublicKey != nil else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerID = PeerID(hexData: noisePublicKey)
|
|
||||||
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
|
||||||
for person in viewModel.visibleGeohashPeople() where person.displayName == name {
|
|
||||||
return person.id
|
|
||||||
}
|
|
||||||
for (pub, nick) in viewModel.geoNicknames where nick == name {
|
|
||||||
return pub
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func startGeohashDM(withPubkeyHex hex: String) {
|
|
||||||
let convKey = PeerID(nostr_: hex)
|
|
||||||
viewModel.nostrKeyMapping[convKey] = hex
|
|
||||||
viewModel.startPrivateChat(with: convKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
|
||||||
viewModel.nostrKeyMapping[senderID]
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
|
||||||
guard let full = viewModel.nostrKeyMapping[convKey] else {
|
|
||||||
return convKey.bare
|
|
||||||
}
|
|
||||||
return viewModel.displayNameForNostrPubkey(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension ChatNostrCoordinator {
|
|
||||||
@MainActor
|
|
||||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
|
||||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
|
||||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
|
||||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
|
||||||
guard encoded.count <= maxEncoded else { return nil }
|
|
||||||
guard let packetData = Base64URLCoding.decode(encoded),
|
|
||||||
packetData.count <= maxBytes
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return BitchatPacket.from(packetData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,714 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
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)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2958
-324
File diff suppressed because it is too large
Load Diff
@@ -1,280 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user