mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:45:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c55e2d4b44 | ||
|
|
e44ba3e5c7 | ||
|
|
f59139c33c |
@@ -71,7 +71,3 @@ __pycache__/
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
TestResult.xcresult/
|
||||
*.xcresult/
|
||||
build.log
|
||||
*.log
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
|
||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||
047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; };
|
||||
@@ -141,12 +140,6 @@
|
||||
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
|
||||
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
|
||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -246,7 +239,6 @@
|
||||
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||
|
||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
||||
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -266,9 +258,6 @@
|
||||
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
|
||||
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
|
||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
|
||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -439,11 +428,9 @@
|
||||
A55126E93155456CAA8D6656 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||
@@ -514,7 +501,6 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
||||
@@ -536,7 +522,6 @@
|
||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||
@@ -735,9 +720,6 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
||||
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
@@ -758,7 +740,6 @@
|
||||
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||
|
||||
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
@@ -793,9 +774,6 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
||||
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
||||
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
@@ -816,7 +794,6 @@
|
||||
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||
|
||||
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
|
||||
@@ -31,13 +31,6 @@ struct BitchatApp: App {
|
||||
.environmentObject(chatViewModel)
|
||||
.onAppear {
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
@@ -109,8 +102,20 @@ struct BitchatApp: App {
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
userDefaults.synchronize()
|
||||
|
||||
// Send the shared content immediately on the main queue
|
||||
// Show notification about shared content
|
||||
DispatchQueue.main.async {
|
||||
// Add system message about sharing
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "preparing to share \(contentType)...",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
self.chatViewModel.messages.append(systemMessage)
|
||||
}
|
||||
|
||||
// Send the shared content after a short delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
|
||||
@@ -173,7 +173,56 @@ struct PendingActions {
|
||||
var setPetname: String?
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: - Privacy Settings
|
||||
|
||||
struct PrivacySettings: Codable {
|
||||
// Level 1: Maximum privacy (default)
|
||||
var persistIdentityCache = false
|
||||
var showLastSeen = false
|
||||
|
||||
// Level 2: Convenience
|
||||
var autoAcceptKnownFingerprints = false
|
||||
var rememberNicknameHistory = false
|
||||
|
||||
// Level 3: Social
|
||||
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
|
||||
}
|
||||
|
||||
// MARK: - Conflict Resolution
|
||||
|
||||
/// Strategies for resolving identity conflicts in the decentralized network.
|
||||
/// Handles cases where multiple peers claim the same nickname or when
|
||||
/// identity mappings become ambiguous due to network partitions.
|
||||
enum ConflictResolution {
|
||||
case acceptNew(petname: String) // "John (2)"
|
||||
case rejectNew
|
||||
case blockFingerprint(String)
|
||||
case alertUser(message: String)
|
||||
}
|
||||
|
||||
// MARK: - UI State
|
||||
|
||||
struct PeerUIState {
|
||||
let peerID: String
|
||||
let nickname: String
|
||||
var identityState: IdentityState
|
||||
var connectionQuality: ConnectionQuality
|
||||
|
||||
enum IdentityState {
|
||||
case unknown // Gray - No identity info
|
||||
case unverifiedKnown(String) // Blue - Handshake done, matches cache
|
||||
case verified(String) // Green - Cryptographically verified
|
||||
case conflict(String, String) // Red - Nickname doesn't match fingerprint
|
||||
case pending // Yellow - Handshake in progress
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionQuality {
|
||||
case excellent
|
||||
case good
|
||||
case poor
|
||||
case disconnected
|
||||
}
|
||||
|
||||
// MARK: - Migration Support
|
||||
//
|
||||
// Removed LegacyFavorite - no longer needed
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -89,4 +89,219 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: - Peer Manager
|
||||
|
||||
/// Manages the collection of peers and their states
|
||||
@MainActor
|
||||
class PeerManager: ObservableObject {
|
||||
@Published var peers: [BitchatPeer] = []
|
||||
@Published var favorites: [BitchatPeer] = []
|
||||
@Published var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
private let meshService: Transport
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
init(meshService: Transport) {
|
||||
self.meshService = meshService
|
||||
updatePeers()
|
||||
|
||||
// Listen for updates
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleFavoriteChanged),
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleFavoriteChanged() {
|
||||
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
updatePeers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func updatePeers() {
|
||||
// Reduce log verbosity - only log when count changes
|
||||
let previousCount = peers.count
|
||||
|
||||
// Get current mesh peers
|
||||
let meshPeers = meshService.getPeerNicknames()
|
||||
|
||||
// Build peer list
|
||||
var allPeers: [BitchatPeer] = []
|
||||
var connectedNicknames: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Add connected mesh peers (only if actually connected or relay connected)
|
||||
for (peerID, nickname) in meshPeers {
|
||||
guard let noiseKey = Data(hexString: peerID) else { continue }
|
||||
|
||||
// Safety check: Never add our own peer ID
|
||||
if peerID == meshService.myPeerID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this peer is actually connected
|
||||
let isConnected = meshService.isPeerConnected(peerID)
|
||||
|
||||
// Skip disconnected peers unless they're favorites (handled later)
|
||||
if !isConnected {
|
||||
continue
|
||||
}
|
||||
|
||||
if isConnected {
|
||||
connectedNicknames.insert(nickname)
|
||||
}
|
||||
|
||||
// Track that we've added this peer ID
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected
|
||||
)
|
||||
// Set favorite status - check both by current noise key and by nickname
|
||||
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
|
||||
peer.favoriteStatus = favoriteStatus
|
||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||
} else {
|
||||
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
|
||||
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
|
||||
if let favorite = favoriteByNickname {
|
||||
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
|
||||
category: SecureLogger.session, level: .info)
|
||||
// Update the favorite's noise key to match the current connection
|
||||
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
|
||||
// Get the updated favorite with the new key
|
||||
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
||||
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
||||
}
|
||||
}
|
||||
allPeers.append(peer)
|
||||
}
|
||||
|
||||
// Add offline favorites (only those not currently connected AND that we actively favorite)
|
||||
|
||||
for (favoriteKey, favorite) in favoritesService.favorites {
|
||||
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
|
||||
|
||||
// Skip if this peer is already connected (by nickname)
|
||||
if connectedNicknames.contains(favorite.peerNickname) {
|
||||
// Skipping favorite - already connected
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already added a peer with this ID (prevents duplicates)
|
||||
if addedPeerIDs.contains(favoriteID) {
|
||||
// Skipping favorite - peer ID already added
|
||||
continue
|
||||
}
|
||||
|
||||
// Only add peers that WE favorite (not just ones who favorite us)
|
||||
if !favorite.isFavorite {
|
||||
// Skipping - we don't favorite them
|
||||
continue
|
||||
}
|
||||
|
||||
// Add this favorite as an offline peer
|
||||
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: favoriteID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
isConnected: false
|
||||
)
|
||||
// Set favorite status
|
||||
peer.favoriteStatus = favorite
|
||||
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
||||
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
|
||||
allPeers.append(peer)
|
||||
}
|
||||
|
||||
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
|
||||
allPeers = allPeers.filter { peer in
|
||||
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
|
||||
}
|
||||
|
||||
// Sort: Connected first, then favorites, then alphabetical
|
||||
allPeers.sort { lhs, rhs in
|
||||
// Direct connections first
|
||||
if lhs.isConnected != rhs.isConnected {
|
||||
return lhs.isConnected
|
||||
}
|
||||
// Then favorites
|
||||
if lhs.isFavorite != rhs.isFavorite {
|
||||
return lhs.isFavorite
|
||||
}
|
||||
// Finally alphabetical
|
||||
return lhs.displayName < rhs.displayName
|
||||
}
|
||||
|
||||
// Single pass to compute all subsets and counts
|
||||
var favorites: [BitchatPeer] = []
|
||||
var mutualFavorites: [BitchatPeer] = []
|
||||
var connectedCount = 0
|
||||
var offlineCount = 0
|
||||
|
||||
for peer in allPeers {
|
||||
if peer.isFavorite {
|
||||
favorites.append(peer)
|
||||
}
|
||||
if peer.isMutualFavorite {
|
||||
mutualFavorites.append(peer)
|
||||
}
|
||||
if peer.isConnected {
|
||||
connectedCount += 1
|
||||
} else {
|
||||
offlineCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Final safety check: ensure no duplicate IDs
|
||||
var finalPeers: [BitchatPeer] = []
|
||||
var seenIDs: Set<String> = []
|
||||
for peer in allPeers {
|
||||
if !seenIDs.contains(peer.id) {
|
||||
seenIDs.insert(peer.id)
|
||||
finalPeers.append(peer)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
self.peers = finalPeers
|
||||
self.favorites = favorites
|
||||
self.mutualFavorites = mutualFavorites
|
||||
|
||||
// Log peer list summary sparingly at debug level
|
||||
if favoritesService.favorites.count > 0 {
|
||||
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else if previousCount != allPeers.count {
|
||||
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFavorite(_ peer: BitchatPeer) {
|
||||
if peer.isFavorite {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
} else {
|
||||
favoritesService.addFavorite(
|
||||
peerNoisePublicKey: peer.noisePublicKey,
|
||||
peerNostrPublicKey: peer.nostrPublicKey,
|
||||
peerNickname: peer.nickname
|
||||
)
|
||||
}
|
||||
updatePeers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||
@@ -13,9 +12,8 @@ struct NostrProtocol {
|
||||
enum EventKind: Int {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case giftWrap = 1059 // NIP-17 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
}
|
||||
|
||||
@@ -32,7 +30,7 @@ struct NostrProtocol {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
kind: .textNote,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
@@ -229,7 +227,7 @@ struct NostrProtocol {
|
||||
return try NostrEvent(from: rumorDict)
|
||||
}
|
||||
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
// MARK: - Encryption (NIP-44 style)
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
@@ -241,31 +239,33 @@ struct NostrProtocol {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
// Encrypting message
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
// Derived shared secret
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
// Generate nonce
|
||||
let nonce = AES.GCM.Nonce()
|
||||
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
// Encrypt
|
||||
let sealed = try AES.GCM.seal(
|
||||
plaintext.data(using: .utf8)!,
|
||||
using: SymmetricKey(data: sharedSecret),
|
||||
nonce: nonce
|
||||
)
|
||||
|
||||
// Combine nonce + ciphertext + tag
|
||||
var result = Data()
|
||||
result.append(nonce.withUnsafeBytes { Data($0) })
|
||||
result.append(sealed.ciphertext)
|
||||
result.append(sealed.tag)
|
||||
|
||||
return result.base64EncodedString()
|
||||
}
|
||||
|
||||
private static func decrypt(
|
||||
@@ -273,45 +273,86 @@ struct NostrProtocol {
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
data.count > (24 + 16),
|
||||
|
||||
// Decrypting message
|
||||
|
||||
guard let data = Data(base64Encoded: ciphertext),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
|
||||
let nonce24 = data.prefix(24)
|
||||
let rest = data.dropFirst(24)
|
||||
let tag = rest.suffix(16)
|
||||
let ct = rest.dropLast(16)
|
||||
|
||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
tag: Data(tag),
|
||||
key: key,
|
||||
nonce24: Data(nonce24)
|
||||
|
||||
// Ciphertext data parsed
|
||||
|
||||
// Extract components
|
||||
let nonceData = data.prefix(12)
|
||||
let ciphertextData = data.dropFirst(12).dropLast(16)
|
||||
let tagData = data.suffix(16)
|
||||
|
||||
// Components parsed
|
||||
|
||||
// Derive shared secret - try with default Y coordinate first
|
||||
var sharedSecret: Data
|
||||
var decrypted: Data? = nil
|
||||
|
||||
do {
|
||||
sharedSecret = try deriveSharedSecret(
|
||||
privateKey: recipientKey,
|
||||
publicKey: senderPubkeyData
|
||||
)
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
// Derived shared secret with first Y coordinate
|
||||
|
||||
// Try to decrypt
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: AES.GCM.Nonce(data: nonceData),
|
||||
ciphertext: ciphertextData,
|
||||
tag: tagData
|
||||
)
|
||||
|
||||
do {
|
||||
decrypted = try AES.GCM.open(
|
||||
sealedBox,
|
||||
using: SymmetricKey(data: sharedSecret)
|
||||
)
|
||||
// AES-GCM decryption successful
|
||||
} catch {
|
||||
// AES-GCM decryption failed, trying alternate
|
||||
|
||||
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
|
||||
if senderPubkeyData.count == 32 {
|
||||
// Trying alternate Y coordinate
|
||||
|
||||
// Force deriveSharedSecret to use odd Y by manipulating the data
|
||||
var altPubkey = Data()
|
||||
altPubkey.append(0x03) // Force odd Y
|
||||
altPubkey.append(senderPubkeyData)
|
||||
|
||||
sharedSecret = try deriveSharedSecretDirect(
|
||||
privateKey: recipientKey,
|
||||
publicKey: altPubkey
|
||||
)
|
||||
|
||||
decrypted = try AES.GCM.open(
|
||||
sealedBox,
|
||||
using: SymmetricKey(data: sharedSecret)
|
||||
)
|
||||
// AES-GCM decryption successful with alternate Y
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let finalDecrypted = decrypted else {
|
||||
throw NostrError.encryptionFailed
|
||||
}
|
||||
|
||||
return String(data: finalDecrypted, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
@@ -371,8 +412,17 @@ struct NostrProtocol {
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// ECDH shared secret derived
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
// Derive key using HKDF for NIP-44 v2
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data(),
|
||||
outputByteCount: 32
|
||||
)
|
||||
|
||||
let result = derivedKey.withUnsafeBytes { Data($0) }
|
||||
// Final derived key ready
|
||||
return result
|
||||
}
|
||||
|
||||
// Direct version that doesn't try to add prefixes
|
||||
@@ -402,8 +452,15 @@ struct NostrProtocol {
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
// Derive key using HKDF for NIP-44 v2
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data(),
|
||||
outputByteCount: 32
|
||||
)
|
||||
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
@@ -416,7 +473,7 @@ struct NostrProtocol {
|
||||
|
||||
// Log with explicit UTC and local time for debugging
|
||||
let formatter = DateFormatter()
|
||||
//
|
||||
// Removed unnecessary date formatting operations
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
|
||||
@@ -480,10 +537,7 @@ struct NostrEvent: Codable {
|
||||
|
||||
// Sign with Schnorr
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
|
||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
@@ -527,32 +581,3 @@ enum NostrError: Error {
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func base64URLEncode(_ data: Data) -> String {
|
||||
return data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s
|
||||
let pad = (4 - (str.count % 4)) % 4
|
||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||
var out = Data(count: 12)
|
||||
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||
// key (8 words)
|
||||
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||
// nonce (4 words)
|
||||
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||
]
|
||||
|
||||
// 20 rounds (10 double rounds)
|
||||
for _ in 0..<10 {
|
||||
// Column rounds
|
||||
quarterRound(&state, 0, 4, 8, 12)
|
||||
quarterRound(&state, 1, 5, 9, 13)
|
||||
quarterRound(&state, 2, 6, 10, 14)
|
||||
quarterRound(&state, 3, 7, 11, 15)
|
||||
// Diagonal rounds
|
||||
quarterRound(&state, 0, 5, 10, 15)
|
||||
quarterRound(&state, 1, 6, 11, 12)
|
||||
quarterRound(&state, 2, 7, 8, 13)
|
||||
quarterRound(&state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
// Output subkey: state[0..3] and state[12..15]
|
||||
var out = Data(count: 32)
|
||||
out.storeLEWord(state[0], at: 0)
|
||||
out.storeLEWord(state[1], at: 4)
|
||||
out.storeLEWord(state[2], at: 8)
|
||||
out.storeLEWord(state[3], at: 12)
|
||||
out.storeLEWord(state[12], at: 16)
|
||||
out.storeLEWord(state[13], at: 20)
|
||||
out.storeLEWord(state[14], at: 24)
|
||||
out.storeLEWord(state[15], at: 28)
|
||||
return out
|
||||
}
|
||||
|
||||
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||
let range = offset..<(offset+4)
|
||||
let bytes = self[range]
|
||||
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||
let b = ptr.bindMemory(to: UInt8.self)
|
||||
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||
let bytes: [UInt8] = [
|
||||
UInt8(value & 0xff),
|
||||
UInt8((value >> 8) & 0xff),
|
||||
UInt8((value >> 16) & 0xff),
|
||||
UInt8((value >> 24) & 0xff)
|
||||
]
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,17 +155,12 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +176,8 @@ enum LazyHandshakeState {
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: - Special Recipients (removed)
|
||||
// Previously defined broadcast identifiers were unused; removed for simplicity.
|
||||
|
||||
// MARK: - Core Protocol Structures
|
||||
|
||||
@@ -267,7 +263,8 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: - Delivery Acknowledgments (removed)
|
||||
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
|
||||
|
||||
// MARK: - Read Receipts
|
||||
|
||||
@@ -359,7 +356,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// PeerIdentityBinding removed (unused).
|
||||
|
||||
|
||||
// MARK: - Delivery Status
|
||||
|
||||
@@ -14,7 +14,7 @@ class AutocompleteService {
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
private let commands = [
|
||||
"/msg", "/who", "/clear",
|
||||
"/msg", "/who", "/clear", "/help",
|
||||
"/hug", "/slap", "/fav", "/unfav",
|
||||
"/block", "/unblock"
|
||||
]
|
||||
@@ -95,10 +95,10 @@ class AutocompleteService {
|
||||
|
||||
private func needsArgument(command: String) -> Bool {
|
||||
switch command {
|
||||
case "/who", "/clear":
|
||||
case "/who", "/clear", "/help":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,16 +112,6 @@ final class BLEService: NSObject {
|
||||
private var scheduledRelays: [String: DispatchWorkItem] = [:]
|
||||
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||||
private var recentPacketTimestamps: [Date] = []
|
||||
|
||||
// Ingress link tracking for last-hop suppression
|
||||
private enum LinkID: Hashable {
|
||||
case peripheral(String)
|
||||
case central(String)
|
||||
}
|
||||
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
|
||||
|
||||
// Backpressure-aware write queue per peripheral
|
||||
private var pendingPeripheralWrites: [String: [Data]] = [:]
|
||||
|
||||
// MARK: - Maintenance Timer
|
||||
|
||||
@@ -166,88 +156,6 @@ final class BLEService: NSObject {
|
||||
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
}
|
||||
|
||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
||||
guard n > 0 else { return 0 }
|
||||
if n <= 2 { return n }
|
||||
// approx ceil(log2(n)) + 1 without floating point
|
||||
var v = n - 1
|
||||
var bits = 0
|
||||
while v > 0 { v >>= 1; bits += 1 }
|
||||
return min(n, max(1, bits + 1))
|
||||
}
|
||||
|
||||
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||
// Stable order by SHA256(seed || "::" || id)
|
||||
var scored: [(score: [UInt8], id: String)] = []
|
||||
for id in ids {
|
||||
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||
let digest = Array(SHA256.hash(data: msg))
|
||||
scored.append((digest, id))
|
||||
}
|
||||
scored.sort { a, b in
|
||||
for i in 0..<min(a.score.count, b.score.count) {
|
||||
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
|
||||
}
|
||||
return a.id < b.id
|
||||
}
|
||||
return Set(scored.prefix(k).map { $0.id })
|
||||
}
|
||||
|
||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||
// BLE operations run on bleQueue; keep queue affinity
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
} else {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
|
||||
var queueCopy: [Data] = []
|
||||
self.collectionsQueue.sync {
|
||||
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
|
||||
}
|
||||
guard !queueCopy.isEmpty else { return }
|
||||
var sent = 0
|
||||
for item in queueCopy {
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(item, for: ch, type: .withoutResponse)
|
||||
sent += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
var q = self.pendingPeripheralWrites[uuid] ?? []
|
||||
if sent <= q.count {
|
||||
q.removeFirst(sent)
|
||||
} else {
|
||||
q.removeAll()
|
||||
}
|
||||
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer snapshots publisher (non-UI convenience)
|
||||
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
||||
@@ -461,7 +369,7 @@ final class BLEService: NSObject {
|
||||
// Send to peripherals we're connected to as central
|
||||
for state in peripherals.values where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,51 +508,11 @@ final class BLEService: NSObject {
|
||||
func sendBroadcastAnnounce() {
|
||||
sendAnnounce()
|
||||
}
|
||||
|
||||
// MARK: - QR Verification over Noise
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
}
|
||||
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
|
||||
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
}
|
||||
|
||||
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(packet)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||
return collectionsQueue.sync {
|
||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
||||
let hash = SHA256.hash(data: publicKey)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -730,7 +598,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Removed unused getPeers(): use getPeerNicknames() from Transport
|
||||
|
||||
// MARK: - Private Message Handling
|
||||
|
||||
@@ -969,7 +837,7 @@ final class BLEService: NSObject {
|
||||
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
|
||||
state.isConnected,
|
||||
let characteristic = state.characteristic {
|
||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
sentEncrypted = true
|
||||
}
|
||||
|
||||
@@ -1000,10 +868,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
|
||||
// Determine last-hop link for this message to avoid echoing back
|
||||
let messageID = makeMessageID(for: packet)
|
||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
||||
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
for s in states where s.isConnected {
|
||||
@@ -1028,54 +892,15 @@ final class BLEService: NSObject {
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
||||
return
|
||||
}
|
||||
// Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link
|
||||
let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }
|
||||
let subscribedCentrals: [CBCentral]
|
||||
var centralIDs: [String] = []
|
||||
if let _ = characteristic {
|
||||
let (centrals, _) = snapshotSubscribedCentrals()
|
||||
subscribedCentrals = centrals
|
||||
centralIDs = centrals.map { $0.identifier.uuidString }
|
||||
} else {
|
||||
subscribedCentrals = []
|
||||
}
|
||||
|
||||
// Exclude ingress link
|
||||
var allowedPeripheralIDs = connectedPeripheralIDs
|
||||
var allowedCentralIDs = centralIDs
|
||||
if let ingress = ingressLink {
|
||||
switch ingress {
|
||||
case .peripheral(let id):
|
||||
allowedPeripheralIDs.removeAll { $0 == id }
|
||||
case .central(let id):
|
||||
allowedCentralIDs.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
|
||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
||||
}
|
||||
|
||||
// Writes to selected connected peripherals
|
||||
// Writes to connected peripherals
|
||||
for s in states where s.isConnected {
|
||||
let pid = s.peripheral.identifier.uuidString
|
||||
guard selectedPeripheralIDs.contains(pid) else { continue }
|
||||
if let ch = s.characteristic {
|
||||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch)
|
||||
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
|
||||
}
|
||||
}
|
||||
// Notify selected subscribed centrals
|
||||
// Notify all subscribed centrals
|
||||
if let ch = characteristic {
|
||||
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
|
||||
if !targets.isEmpty {
|
||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
|
||||
}
|
||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,7 +914,7 @@ final class BLEService: NSObject {
|
||||
|
||||
// Fire-and-forget principle: always use .withoutResponse for speed
|
||||
// CoreBluetooth will handle fragmentation at L2CAP layer
|
||||
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
}
|
||||
|
||||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||||
@@ -1297,7 +1122,6 @@ final class BLEService: NSObject {
|
||||
ttl: packet.ttl,
|
||||
senderIsSelf: senderID == myPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
degree: degree,
|
||||
@@ -1559,16 +1383,6 @@ final class BLEService: NSObject {
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyChallenge:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyResponse:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
default:
|
||||
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
|
||||
}
|
||||
@@ -1863,15 +1677,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean ingress link records older than 3 seconds
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let cutoff = now.addingTimeInterval(-3)
|
||||
if !self.ingressByMessageID.isEmpty {
|
||||
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateScanningDutyCycle(connectedCount: Int) {
|
||||
@@ -2262,27 +2067,6 @@ extension BLEService {
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = packet.senderID.hexEncodedString()
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
id: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
}
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
} else {
|
||||
@@ -2411,22 +2195,12 @@ extension BLEService: CBPeripheralDelegate {
|
||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||
// Mapping update - direct announce from peer
|
||||
}
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
}
|
||||
// Process the announce packet regardless of whether we updated the mapping
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
} else {
|
||||
// For non-announce packets, DO NOT update mappings
|
||||
// These could be relayed packets from other peers
|
||||
// Always use the packet's original senderID
|
||||
// Record ingress link for last-hop suppression and process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||
}
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
}
|
||||
}
|
||||
@@ -2441,8 +2215,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
// Resume queued writes for this peripheral
|
||||
drainPendingWrites(for: peripheral)
|
||||
// Suppress verbose ready logs
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||
@@ -2666,18 +2439,8 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
|
||||
// Record ingress link for last-hop suppression then process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
||||
}
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
} else {
|
||||
// Record ingress link for last-hop suppression then process
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
||||
}
|
||||
handleReceivedPacket(packet, from: senderID)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -33,15 +33,6 @@ class CommandProcessor {
|
||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
let inGeoPublic: Bool = {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .mesh: return false
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
return handleMessage(args)
|
||||
@@ -58,14 +49,11 @@ class CommandProcessor {
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
//
|
||||
case "/help", "/h":
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
return handleHelp()
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
}
|
||||
@@ -97,34 +85,19 @@ class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
let names = people.map { $0.displayName }
|
||||
if names.isEmpty { return .success(message: "no one else is online right now") }
|
||||
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
||||
case .mesh:
|
||||
// Mesh context: show connected peer nicknames
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
}
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
}
|
||||
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.clearCurrentPublicTimeline()
|
||||
chatViewModel?.messages.removeAll()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
#if os(iOS)
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
@@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
if #available(iOS 14.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
@@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
if #available(iOS 14.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
@@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
@@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+ / macOS 11+
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
// iOS 14+
|
||||
@available(iOS 14.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
@@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
@@ -256,4 +256,5 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,7 +12,6 @@ struct RelayController {
|
||||
static func decide(ttl: UInt8,
|
||||
senderIsSelf: Bool,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
degree: Int,
|
||||
@@ -20,17 +19,7 @@ struct RelayController {
|
||||
// Suppress obvious non-relays
|
||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||
|
||||
// For session-critical or directed traffic, be deterministic and reliable
|
||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||
// Always relay with no TTL cap for these types
|
||||
let newTTL = (ttl &- 1)
|
||||
// Slight jitter to desynchronize without adding too much latency
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
||||
let delayMs = Int.random(in: delayRange)
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
||||
// Degree-aware probability to reduce floods in dense graphs
|
||||
let baseProb: Double
|
||||
switch degree {
|
||||
case 0...2: baseProb = 1.0
|
||||
@@ -39,22 +28,20 @@ struct RelayController {
|
||||
case 7...9: baseProb = 0.55
|
||||
default: baseProb = 0.45
|
||||
}
|
||||
let prob = baseProb
|
||||
var prob = baseProb
|
||||
if isHandshake { prob = max(0.3, baseProb - 0.2) }
|
||||
|
||||
// Sample a forwarding decision
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping in dense graphs (only for broadcast)
|
||||
// TTL clamping in dense graphs
|
||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||
let clamped = max(1, min(ttl, ttlCap))
|
||||
let newTTL = clamped &- 1
|
||||
|
||||
// Wider jitter window to allow duplicate suppression to win more often
|
||||
let delayMs: Int
|
||||
switch degree {
|
||||
case 0...2: delayMs = Int.random(in: 40...100)
|
||||
case 3...5: delayMs = Int.random(in: 60...150)
|
||||
case 6...9: delayMs = Int.random(in: 80...180)
|
||||
default: delayMs = Int.random(in: 100...220)
|
||||
}
|
||||
// Short jitter to desynchronize rebroadcasts
|
||||
let delayMs = Int.random(in: 20...80)
|
||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,20 +46,11 @@ protocol Transport: AnyObject {
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||
final class VerificationService {
|
||||
static let shared = VerificationService()
|
||||
|
||||
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||
private var noise: NoiseEncryptionService?
|
||||
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||
|
||||
/// Encapsulates the data encoded into a verification QR
|
||||
struct VerificationQR: Codable {
|
||||
let v: Int
|
||||
let noiseKeyHex: String
|
||||
let signKeyHex: String
|
||||
let npub: String?
|
||||
let nickname: String
|
||||
let ts: Int64
|
||||
let nonceB64: String
|
||||
var sigHex: String
|
||||
|
||||
static let context = "bitchat-verify-v1"
|
||||
|
||||
/// Canonical bytes used for signature (deterministic ordering)
|
||||
func canonicalBytes() -> Data {
|
||||
var out = Data()
|
||||
func appendField(_ s: String) {
|
||||
let d = s.data(using: .utf8) ?? Data()
|
||||
out.append(UInt8(min(d.count, 255)))
|
||||
out.append(d.prefix(255))
|
||||
}
|
||||
appendField(Self.context)
|
||||
appendField(String(v))
|
||||
appendField(noiseKeyHex.lowercased())
|
||||
appendField(signKeyHex.lowercased())
|
||||
appendField(npub ?? "")
|
||||
appendField(nickname)
|
||||
appendField(String(ts))
|
||||
appendField(nonceB64)
|
||||
return out
|
||||
}
|
||||
|
||||
func toURLString() -> String {
|
||||
var comps = URLComponents()
|
||||
comps.scheme = "bitchat"
|
||||
comps.host = "verify"
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "v", value: String(v)),
|
||||
URLQueryItem(name: "noise", value: noiseKeyHex),
|
||||
URLQueryItem(name: "sign", value: signKeyHex),
|
||||
URLQueryItem(name: "nick", value: nickname),
|
||||
URLQueryItem(name: "ts", value: String(ts)),
|
||||
URLQueryItem(name: "nonce", value: nonceB64),
|
||||
URLQueryItem(name: "sig", value: sigHex)
|
||||
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
|
||||
return comps.string ?? ""
|
||||
}
|
||||
|
||||
static func fromURL(_ url: URL) -> VerificationQR? {
|
||||
guard url.scheme == "bitchat", url.host == "verify",
|
||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
|
||||
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
|
||||
guard let vStr = val("v"), let v = Int(vStr),
|
||||
let noise = val("noise"), let sign = val("sign"),
|
||||
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
|
||||
let nonce = val("nonce"), let sig = val("sig") else { return nil }
|
||||
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Build a signed QR string for the current identity
|
||||
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
||||
// Simple short-lived cache to speed up sheet opening
|
||||
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
|
||||
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||
return c.value
|
||||
}
|
||||
guard let noise = noise else { return nil }
|
||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
guard let sig = noise.signData(msg) else { return nil }
|
||||
let signed = VerificationQR(v: payload.v,
|
||||
noiseKeyHex: payload.noiseKeyHex,
|
||||
signKeyHex: payload.signKeyHex,
|
||||
npub: payload.npub,
|
||||
nickname: payload.nickname,
|
||||
ts: payload.ts,
|
||||
nonceB64: payload.nonceB64,
|
||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||
let out = signed.toURLString()
|
||||
Cache.last = (nickname, npub, Date(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? {
|
||||
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||
// Freshness
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let noise = noise else { return nil }
|
||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
return ok ? qr : nil
|
||||
}
|
||||
|
||||
// MARK: - Noise payloads (scaffold only)
|
||||
|
||||
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
|
||||
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
|
||||
var tlv = Data()
|
||||
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
|
||||
tlv.append(contentsOf: n0)
|
||||
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
|
||||
tlv.append(0x02)
|
||||
tlv.append(UInt8(min(nonceA.count, 255)))
|
||||
tlv.append(nonceA.prefix(255))
|
||||
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
|
||||
}
|
||||
|
||||
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
|
||||
// Sign context: verify-response | noiseKeyHex | nonceA
|
||||
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||
var tlv = Data()
|
||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
||||
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
||||
}
|
||||
|
||||
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
|
||||
var idx = 0
|
||||
func take(_ n: Int) -> Data? {
|
||||
guard idx + n <= data.count else { return nil }
|
||||
let d = data[idx..<(idx+n)]
|
||||
idx += n
|
||||
return Data(d)
|
||||
}
|
||||
// Expect type already stripped; we receive only TLV here
|
||||
// TLV 0x01 noiseKeyHex
|
||||
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||
// TLV 0x02 nonceA
|
||||
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||
return (noiseStr, nA)
|
||||
}
|
||||
|
||||
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
|
||||
var idx = 0
|
||||
func take(_ n: Int) -> Data? {
|
||||
guard idx + n <= data.count else { return nil }
|
||||
let d = data[idx..<(idx+n)]
|
||||
idx += n
|
||||
return Data(d)
|
||||
}
|
||||
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
|
||||
return (noiseStr, nA, sig)
|
||||
}
|
||||
|
||||
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
|
||||
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,14 @@ struct InputValidator {
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
// Accept short routing IDs (exact 16-hex)
|
||||
// Accept short routing IDs (16-hex)
|
||||
if PeerIDResolver.isShortID(peerID) { return true }
|
||||
// If length equals short-hex length but isn't valid hex, reject
|
||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
||||
// Accept full Noise key hex (exact 64-hex)
|
||||
// Accept full Noise key hex (64-hex)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||
// If length equals full key length but isn't valid hex, reject
|
||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
// Internal format: alphanumeric + dash/underscore up to 64
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !peerID.isEmpty &&
|
||||
peerID.count < Limits.maxPeerIDLength &&
|
||||
peerID.count <= Limits.maxPeerIDLength &&
|
||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Missing properties that were removed during refactoring
|
||||
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
|
||||
private var selectedPrivateChatFingerprint: String? = nil
|
||||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||||
@@ -352,12 +352,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private let maxProcessedNostrEvents = 2000
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
// Location channel state (macOS supports manual geohash selection)
|
||||
// Location channel state
|
||||
#if os(iOS)
|
||||
@Published private var activeChannel: ChannelID = .mesh
|
||||
private var geoSubscriptionID: String? = nil
|
||||
private var geoDmSubscriptionID: String? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
#endif
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
@@ -387,6 +389,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Persist mesh public timeline across channel switches
|
||||
private var meshTimeline: [BitchatMessage] = []
|
||||
private let meshTimelineCap = 1337
|
||||
#if os(iOS)
|
||||
// Persist per-geohash public timelines across switches
|
||||
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
|
||||
private let geoTimelineCap = 1337
|
||||
@@ -402,28 +405,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
|
||||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
||||
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||||
#endif
|
||||
|
||||
// MARK: - Message Delivery Tracking
|
||||
|
||||
// Delivery tracking
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - QR Verification (pending state)
|
||||
private struct PendingVerification {
|
||||
let noiseKeyHex: String
|
||||
let signKeyHex: String
|
||||
let nonceA: Data
|
||||
let startedAt: Date
|
||||
var sent: Bool
|
||||
}
|
||||
private var pendingQRVerifications: [String: PendingVerification] = [:] // peerID -> pending
|
||||
// Last handled challenge nonce per peer to avoid duplicate responses
|
||||
private var lastVerifyNonceByPeer: [String: Data] = [:]
|
||||
// Track when we last received a verify challenge from a peer (fingerprint-keyed)
|
||||
private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint
|
||||
// Throttle mutual verification toasts per fingerprint
|
||||
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
|
||||
|
||||
// MARK: - Public message batching (UI perf)
|
||||
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
||||
private var publicBuffer: [BitchatMessage] = []
|
||||
@@ -462,9 +450,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle verification response toasts per peer to avoid spam
|
||||
private var lastVerifyToastAt: [String: Date] = [:]
|
||||
|
||||
// Track processed Nostr ACKs to avoid duplicate processing
|
||||
private var processedNostrAcks: Set<String> = [] // "messageId:ackType:senderPubkey" format
|
||||
@@ -588,7 +573,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
self.cancellables.insert(cancellable)
|
||||
|
||||
// Resubscribe geohash on relay reconnect
|
||||
// Resubscribe geohash on relay reconnect (iOS only)
|
||||
#if os(iOS)
|
||||
if let relayMgr = self.nostrRelayManager {
|
||||
relayMgr.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -602,11 +588,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Set up Noise encryption callbacks
|
||||
setupNoiseCallbacks()
|
||||
|
||||
#if os(iOS)
|
||||
// Observe location channel selection
|
||||
LocationChannelManager.shared.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -621,6 +609,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
Task { @MainActor in
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Request notification permission
|
||||
NotificationService.shared.requestAuthorization()
|
||||
@@ -706,7 +695,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Force immediate save
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
|
||||
#if os(iOS)
|
||||
// Resubscribe to the active geohash channel without clearing timeline
|
||||
@MainActor
|
||||
private func resubscribeCurrentGeohash() {
|
||||
@@ -729,11 +719,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.processedNostrEvents.insert(event.id)
|
||||
if let gh = self.currentGeohash,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||
}
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1]
|
||||
self.geoNicknames[event.pubkey.lowercased()] = nick
|
||||
@@ -847,14 +833,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
// Notify for truly unread and recent messages when not viewing
|
||||
if shouldMarkUnread {
|
||||
// Optionally notify if app backgrounded and message is truly unread
|
||||
#if os(iOS)
|
||||
if shouldMarkUnread && UIApplication.shared.applicationState != .active {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
case .delivered:
|
||||
@@ -881,13 +869,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Nickname Management
|
||||
|
||||
@@ -1215,6 +1201,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
// Add message to local display
|
||||
#if os(iOS)
|
||||
var displaySender = nickname
|
||||
var localSenderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
@@ -1223,6 +1210,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
displaySender = nickname + "#" + suffix
|
||||
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))"
|
||||
}
|
||||
#else
|
||||
let displaySender = nickname
|
||||
let localSenderPeerID = meshService.myPeerID
|
||||
#endif
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: displaySender,
|
||||
@@ -1242,6 +1233,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let ckey = normalizedContentKey(message.content)
|
||||
recordContentKey(ckey, timestamp: message.timestamp)
|
||||
// Persist to channel-specific timelines
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
@@ -1252,19 +1244,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
#else
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
#endif
|
||||
trimMessagesIfNeeded()
|
||||
|
||||
// Force immediate UI update for user's own messages
|
||||
objectWillChange.send()
|
||||
|
||||
// Update channel activity time on send
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
lastPublicActivityAt["mesh"] = Date()
|
||||
case .location(let ch):
|
||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if case .location(let ch) = activeChannel {
|
||||
// Send to geohash channel via Nostr ephemeral
|
||||
Task { @MainActor in
|
||||
@@ -1303,10 +1302,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Send via mesh with mentions
|
||||
meshService.sendMessage(content, mentions: mentions)
|
||||
}
|
||||
|
||||
#else
|
||||
// Send via mesh with mentions (non-iOS)
|
||||
meshService.sendMessage(content, mentions: mentions)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
private func switchLocationChannel(to channel: ChannelID) {
|
||||
// Flush pending public buffer to avoid cross-channel bleed
|
||||
@@ -1349,12 +1352,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||
#if os(iOS)
|
||||
if LocationChannelManager.shared.teleported {
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
geoSubscriptionID = subID
|
||||
@@ -1384,12 +1389,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
}
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
// Skip our own events (we already locally echoed)
|
||||
if let gh = self.currentGeohash,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||
return
|
||||
}
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
@@ -1517,14 +1521,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} else {
|
||||
// pared back: omit defer READ log
|
||||
}
|
||||
// Notify for truly unread and recent messages when not viewing
|
||||
// Notify only when app is backgrounded and not viewing, and only if not already read
|
||||
#if os(iOS)
|
||||
if !isViewing && shouldMarkUnread {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
if UIApplication.shared.applicationState != .active {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
// pared back: omit notification log
|
||||
}
|
||||
}
|
||||
#endif
|
||||
self.objectWillChange.send()
|
||||
default:
|
||||
// Handle delivered/read receipts for our sent messages
|
||||
@@ -1561,10 +1570,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
//
|
||||
// Presence announcement removed; we will tag actual chat events instead
|
||||
}
|
||||
|
||||
// MARK: - Geohash Participants
|
||||
// MARK: - Geohash Participants (iOS)
|
||||
#if os(iOS)
|
||||
struct GeoPerson: Identifiable, Equatable {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
@@ -1622,8 +1632,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
geoParticipantsTimer?.invalidate()
|
||||
geoParticipantsTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Public helpers
|
||||
#endif
|
||||
|
||||
// MARK: - Public helpers (iOS)
|
||||
#if os(iOS)
|
||||
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
|
||||
@MainActor
|
||||
func visibleGeohashPeople() -> [GeoPerson] {
|
||||
@@ -1714,7 +1726,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let toAdd = desired.subtracting(current)
|
||||
let toRemove = current.subtracting(desired)
|
||||
|
||||
//
|
||||
// Unsubscribe removed
|
||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
geoSamplingSubs.removeValue(forKey: subID)
|
||||
@@ -1741,6 +1753,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
||||
geoSamplingSubs.removeAll()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||
let suffix = String(pubkeyHex.suffix(4))
|
||||
@@ -1760,12 +1773,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Helper: display name for current active channel (for notifications)
|
||||
private func activeChannelDisplayName() -> String {
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .location(let ch):
|
||||
return "#\(ch.geohash)"
|
||||
}
|
||||
#else
|
||||
return "#mesh"
|
||||
#endif
|
||||
}
|
||||
|
||||
// Dedup helper with small memory cap
|
||||
@@ -1782,6 +1799,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Sends an encrypted private message to a specific peer.
|
||||
/// - Parameters:
|
||||
@@ -1793,6 +1811,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
// Geohash DM routing: conversation keys start with "nostr_"
|
||||
#if os(iOS)
|
||||
if peerID.hasPrefix("nostr_") {
|
||||
guard case .location(let ch) = activeChannel else {
|
||||
addSystemMessage("cannot send: not in a location channel")
|
||||
@@ -1858,6 +1877,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
// Check if blocked
|
||||
if unifiedPeerService.isBlocked(peerID) {
|
||||
@@ -1924,6 +1944,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Geohash DMs initiation
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
@@ -1949,6 +1970,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
}
|
||||
#endif
|
||||
/// Add a local system message to a private chat (no network send)
|
||||
@MainActor
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
|
||||
@@ -2402,7 +2424,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
// Also resubscribe the current geohash channel if active
|
||||
#if os(iOS)
|
||||
resubscribeCurrentGeohash()
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -2447,6 +2471,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
} else {
|
||||
// In public chat - send to active public channel
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||
@@ -2480,7 +2505,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||
#endif
|
||||
|
||||
// Show local notification immediately as system message
|
||||
let localNotification = BitchatMessage(
|
||||
@@ -2560,6 +2587,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
privateChatManager.markAsRead(from: peerID)
|
||||
|
||||
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
|
||||
#if os(iOS)
|
||||
if peerID.hasPrefix("nostr_"),
|
||||
let recipientHex = nostrKeyMapping[peerID],
|
||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
@@ -2577,6 +2605,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
// Get the peer's Noise key to check for Nostr messages
|
||||
var noiseKeyHex: String? = nil
|
||||
@@ -2673,6 +2702,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
func getPeerIDForNickname(_ nickname: String) -> String? {
|
||||
#if os(iOS)
|
||||
// When in a geohash channel, allow resolving by geohash participant nickname
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .location:
|
||||
@@ -2686,10 +2716,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
nostrKeyMapping[convKey] = pub
|
||||
return convKey
|
||||
}
|
||||
default:
|
||||
break
|
||||
default: break
|
||||
}
|
||||
// Fallback to mesh nickname resolution
|
||||
#endif
|
||||
return unifiedPeerService.getPeerID(for: nickname)
|
||||
}
|
||||
|
||||
@@ -2797,6 +2826,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
// Build candidate list based on active channel
|
||||
let peerCandidates: [String] = {
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
let values = meshService.getPeerNicknames().values
|
||||
@@ -2815,6 +2845,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return Array(tokens)
|
||||
}
|
||||
#else
|
||||
let values = meshService.getPeerNicknames().values
|
||||
return Array(values.filter { $0 != meshService.myNickname })
|
||||
#endif
|
||||
}()
|
||||
|
||||
let (suggestions, range) = autocompleteService.getSuggestions(
|
||||
@@ -2935,12 +2969,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Determine if this message was sent by self (mesh, geo, or DM)
|
||||
let isSelf: Bool = {
|
||||
if let spid = message.senderPeerID {
|
||||
// In geohash channels, compare against our per-geohash nostr short ID
|
||||
#if os(iOS)
|
||||
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
|
||||
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return spid == meshService.myPeerID
|
||||
}
|
||||
// Fallback by nickname
|
||||
@@ -3091,9 +3126,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
|
||||
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
||||
let mySuffix: String? = {
|
||||
#if os(iOS)
|
||||
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return String(id.publicKeyHex.suffix(4))
|
||||
}
|
||||
#endif
|
||||
return String(meshService.myPeerID.prefix(4))
|
||||
}()
|
||||
let isMentionToMe: Bool = {
|
||||
@@ -3126,28 +3163,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let token = String(matchText.dropFirst()).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
||||
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
|
||||
let attachedToMention: Bool = {
|
||||
// nsRange is the Range<String.Index> for this match within content
|
||||
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
|
||||
if nsRange.lowerBound > content.startIndex {
|
||||
var i = content.index(before: nsRange.lowerBound)
|
||||
while true {
|
||||
let ch = content[i]
|
||||
if ch.isWhitespace || ch.isNewline { break }
|
||||
if ch == "@" { return true }
|
||||
if i == content.startIndex { break }
|
||||
i = content.index(before: i)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
var tagStyle = AttributeContainer()
|
||||
tagStyle.font = isSelf
|
||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||
: .system(size: 14, design: .monospaced)
|
||||
tagStyle.foregroundColor = baseColor
|
||||
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||
if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||
tagStyle.link = url
|
||||
tagStyle.underlineStyle = .single
|
||||
}
|
||||
@@ -3545,6 +3566,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||
@MainActor
|
||||
func clearCurrentPublicTimeline() {
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
messages.removeAll()
|
||||
@@ -3553,6 +3575,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
messages.removeAll()
|
||||
geoTimelines[ch.geohash] = []
|
||||
}
|
||||
#else
|
||||
messages.removeAll()
|
||||
meshTimeline.removeAll()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
|
||||
@@ -3617,8 +3643,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return unifiedPeerService.getFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
private func getFingerprint_old(for peerID: String) -> String? {
|
||||
// Remove debug logging to prevent console spam during view updates
|
||||
|
||||
// First try to get fingerprint from mesh service's peer ID rotation mapping
|
||||
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
// Check noise service (direct Noise session fingerprint)
|
||||
if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) {
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
// Last resort: check local mapping
|
||||
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to resolve nickname for a peer ID through various sources
|
||||
@MainActor
|
||||
@@ -3684,35 +3728,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Update encryption status after verification
|
||||
updateEncryptionStatus(for: peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unverifyFingerprint(for peerID: String) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false)
|
||||
SecureIdentityStateManager.shared.forceSave()
|
||||
verifiedFingerprints.remove(fingerprint)
|
||||
updateEncryptionStatus(for: peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadVerifiedFingerprints() {
|
||||
// Load verified fingerprints directly from secure storage
|
||||
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
||||
// Log snapshot for debugging persistence
|
||||
let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ")
|
||||
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info)
|
||||
// Also log any offline favorites and whether we consider them verified
|
||||
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
|
||||
for fav in offlineFavorites {
|
||||
let fp = unifiedPeerService.getFingerprint(for: fav.id)
|
||||
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
|
||||
let fpShort = fp?.prefix(8) ?? "nil"
|
||||
SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info)
|
||||
}
|
||||
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately
|
||||
invalidateEncryptionCache()
|
||||
// Trigger UI refresh of peer list
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
private func setupNoiseCallbacks() {
|
||||
@@ -3746,14 +3765,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
|
||||
pending.sent = true
|
||||
self.pendingQRVerifications[peerID] = pending
|
||||
SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug)
|
||||
}
|
||||
|
||||
// Schedule UI update
|
||||
// UI will update automatically
|
||||
}
|
||||
@@ -3857,72 +3868,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
case .verifyChallenge:
|
||||
// Parse and respond
|
||||
guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
||||
// Ensure intended for our noise key
|
||||
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
|
||||
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
||||
// Deduplicate: ignore if we've already responded to this nonce for this peer
|
||||
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return }
|
||||
lastVerifyNonceByPeer[peerID] = tlv.nonceA
|
||||
// Record inbound challenge time keyed by stable fingerprint if available
|
||||
if let fp = getFingerprint(for: peerID) {
|
||||
lastInboundVerifyChallengeAt[fp] = Date()
|
||||
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
|
||||
if verifiedFingerprints.contains(fp) {
|
||||
let now = Date()
|
||||
let last = lastMutualToastAt[fp] ?? .distantPast
|
||||
if now.timeIntervalSince(last) > 60 { // 1-minute throttle
|
||||
lastMutualToastAt[fp] = now
|
||||
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: "Mutual verification",
|
||||
body: "You and \(name) verified each other",
|
||||
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
|
||||
// Silent response: no toast needed on responder
|
||||
case .verifyResponse:
|
||||
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
|
||||
// Check pending for this peer
|
||||
guard let pending = pendingQRVerifications[peerID] else { return }
|
||||
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
|
||||
// Verify signature with expected sign key
|
||||
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
|
||||
if ok {
|
||||
pendingQRVerifications.removeValue(forKey: peerID)
|
||||
if let fp = getFingerprint(for: peerID) {
|
||||
let short = fp.prefix(8)
|
||||
SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info)
|
||||
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true)
|
||||
SecureIdentityStateManager.shared.forceSave()
|
||||
verifiedFingerprints.insert(fp)
|
||||
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID)
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: "Verified",
|
||||
body: "You verified \(name)",
|
||||
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
||||
)
|
||||
// If we also recently responded to their challenge, flag mutual and toast (initiator side)
|
||||
if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 {
|
||||
let now = Date()
|
||||
let lastToast = lastMutualToastAt[fp] ?? .distantPast
|
||||
if now.timeIntervalSince(lastToast) > 60 {
|
||||
lastMutualToastAt[fp] = now
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: "Mutual verification",
|
||||
body: "You and \(name) verified each other",
|
||||
identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
}
|
||||
updateEncryptionStatus(for: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3948,36 +3893,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR Verification API
|
||||
@MainActor
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
// Find a matching peer by Noise key
|
||||
let targetNoise = qr.noiseKeyHex.lowercased()
|
||||
guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
|
||||
return false
|
||||
}
|
||||
let peerID = peer.id
|
||||
// If we already have a pending verification with this peer, don't send another
|
||||
if pendingQRVerifications[peerID] != nil {
|
||||
return true
|
||||
}
|
||||
// Generate nonceA
|
||||
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
|
||||
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
|
||||
let noise = meshService.getNoiseService()
|
||||
if noise.hasEstablishedSession(with: peerID) {
|
||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
||||
pending.sent = true
|
||||
pendingQRVerifications[peerID] = pending
|
||||
} else {
|
||||
meshService.triggerHandshake(with: peerID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Mention parsing moved from BLE – use the existing non-optional helper below
|
||||
// MARK: - Peer Connection Events
|
||||
|
||||
@@ -4016,7 +3931,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
messageRouter.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
//
|
||||
// Connection messages removed to reduce chat noise
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: String) {
|
||||
@@ -4083,7 +3998,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Disconnection messages removed to reduce chat noise
|
||||
}
|
||||
|
||||
func didUpdatePeerList(_ peers: [String]) {
|
||||
@@ -4367,6 +4282,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Used for emotes where we want a local system-style confirmation instead.
|
||||
@MainActor
|
||||
func sendPublicRaw(_ content: String) {
|
||||
#if os(iOS)
|
||||
if case .location(let ch) = activeChannel {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
@@ -4390,13 +4306,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Default: send over mesh
|
||||
meshService.sendMessage(content, mentions: [])
|
||||
}
|
||||
|
||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||
|
||||
//
|
||||
// Removed inlined Nostr send helpers in favor of MessageRouter
|
||||
|
||||
@MainActor
|
||||
private func setupNostrMessageHandling() {
|
||||
@@ -4565,18 +4482,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
privateChatManager.sanitizeChat(for: ephemeralPeerID)
|
||||
}
|
||||
|
||||
// Send delivery ack via Nostr embedded
|
||||
if !wasReadBefore {
|
||||
if let key = actualSenderNoiseKey {
|
||||
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
||||
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
||||
let nt = NostrTransport()
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.log("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
|
||||
if !wasReadBefore, let key = actualSenderNoiseKey {
|
||||
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
||||
}
|
||||
|
||||
if wasReadBefore {
|
||||
@@ -4587,19 +4496,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
|
||||
unreadPrivateMessages.remove(ephemeralPeerID)
|
||||
}
|
||||
if !sentReadReceipts.contains(messageId) {
|
||||
if let key = actualSenderNoiseKey {
|
||||
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
|
||||
sentReadReceipts.insert(messageId)
|
||||
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport()
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||
sentReadReceipts.insert(messageId)
|
||||
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
|
||||
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
|
||||
sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
if shouldMarkAsUnread {
|
||||
@@ -4637,9 +4538,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
|
||||
objectWillChange.send()
|
||||
}
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
// Ignore verification payloads arriving via Nostr path for now
|
||||
break
|
||||
}
|
||||
|
||||
} catch {
|
||||
@@ -4727,7 +4625,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
//
|
||||
// Removed local TLV decoder; using PrivateMessagePacket.decode from Protocols
|
||||
|
||||
@MainActor
|
||||
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
||||
@@ -5297,7 +5195,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
trimMeshTimelineIfNeeded()
|
||||
}
|
||||
|
||||
// Persist geochat messages to per-geohash timeline
|
||||
// Persist geochat messages to per-geohash timeline (iOS-only)
|
||||
#if os(iOS)
|
||||
if isGeo && finalMessage.sender != "system" {
|
||||
if let gh = currentGeohash {
|
||||
var arr = geoTimelines[gh] ?? []
|
||||
@@ -5306,14 +5205,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
geoTimelines[gh] = arr
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Only add message to current timeline if it matches active channel or is system
|
||||
let isSystem = finalMessage.sender == "system"
|
||||
let channelMatches: Bool = {
|
||||
#if os(iOS)
|
||||
switch activeChannel {
|
||||
case .mesh: return !isGeo || isSystem
|
||||
case .location: return isGeo || isSystem
|
||||
}
|
||||
#else
|
||||
// On non-iOS builds, we don't have location channels; accept all
|
||||
return true
|
||||
#endif
|
||||
}()
|
||||
|
||||
guard channelMatches else { return }
|
||||
|
||||
+157
-76
@@ -13,9 +13,19 @@ import UIKit
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
//
|
||||
// Pre-computed peer data for performance
|
||||
struct PeerDisplayData: Identifiable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let isFavorite: Bool
|
||||
let isMe: Bool
|
||||
let hasUnreadMessages: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let connectionState: BitchatPeer.ConnectionState
|
||||
let isMutualFavorite: Bool
|
||||
}
|
||||
|
||||
//
|
||||
// (Link previews removed; URLs are now clickable inline)
|
||||
|
||||
// MARK: - Main Content View
|
||||
|
||||
@@ -23,7 +33,9 @@ struct ContentView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
#if os(iOS)
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
#endif
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -46,7 +58,6 @@ struct ContentView: View {
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
@State private var autocompleteDebounceTimer: Timer?
|
||||
@State private var showLocationChannelsSheet = false
|
||||
@State private var showVerifySheet = false
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@@ -188,6 +199,7 @@ struct ContentView: View {
|
||||
|
||||
Button("direct message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
#if os(iOS)
|
||||
if peerID.hasPrefix("nostr:") {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
@@ -195,6 +207,9 @@ struct ContentView: View {
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
#else
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
#endif
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
@@ -216,6 +231,7 @@ struct ContentView: View {
|
||||
|
||||
Button("BLOCK", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
#if os(iOS)
|
||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
let sender = selectedMessageSender {
|
||||
@@ -223,6 +239,9 @@ struct ContentView: View {
|
||||
} else if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/block \(sender)")
|
||||
}
|
||||
#else
|
||||
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
|
||||
#endif
|
||||
}
|
||||
|
||||
Button("cancel", role: .cancel) {}
|
||||
@@ -270,13 +289,16 @@ struct ContentView: View {
|
||||
let windowedMessages = messages.suffix(currentWindowCount)
|
||||
|
||||
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer { return "dm:\(peer)" }
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||
|
||||
ForEach(items, id: \.uiID) { item in
|
||||
@@ -372,13 +394,16 @@ struct ContentView: View {
|
||||
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
||||
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
||||
let step = 200
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer { return "dm:\(peer)" }
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
let preserveID = "\(contextKey)|\(message.id)"
|
||||
if let peer = privatePeer {
|
||||
let current = windowCountPrivate[peer] ?? 300
|
||||
@@ -447,6 +472,7 @@ struct ContentView: View {
|
||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||
#if os(iOS)
|
||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .region
|
||||
@@ -461,6 +487,7 @@ struct ContentView: View {
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||
#endif
|
||||
}
|
||||
.onTapGesture(count: 3) {
|
||||
// Triple-tap to clear current chat
|
||||
@@ -473,12 +500,16 @@ struct ContentView: View {
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||
return nil
|
||||
}()
|
||||
@@ -493,12 +524,16 @@ struct ContentView: View {
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||
return nil
|
||||
}()
|
||||
@@ -512,12 +547,16 @@ struct ContentView: View {
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||
return nil
|
||||
}()
|
||||
@@ -543,12 +582,16 @@ struct ContentView: View {
|
||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||
// Immediate scroll if enough time has passed
|
||||
lastScrollTime = now
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
let count = windowCountPublic
|
||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
@@ -559,12 +602,16 @@ struct ContentView: View {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||
lastScrollTime = Date()
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#if os(iOS)
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
#else
|
||||
let contextKey: String = "mesh"
|
||||
#endif
|
||||
let count = windowCountPublic
|
||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
@@ -611,6 +658,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
@@ -629,6 +677,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
if let peerID = privatePeer {
|
||||
@@ -698,22 +747,18 @@ struct ContentView: View {
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Define commands with aliases and syntax
|
||||
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/block"], "[nickname]", "block or list blocked peers"),
|
||||
(["/clear"], nil, "clear chat messages"),
|
||||
(["/fav"], "<nickname>", "add to favorites"),
|
||||
(["/help"], nil, "show this help"),
|
||||
(["/hug"], "<nickname>", "send someone a warm hug"),
|
||||
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
||||
(["/slap"], "<nickname>", "slap someone with a trout"),
|
||||
(["/unblock"], "<nickname>", "unblock a peer"),
|
||||
(["/unfav"], "<nickname>", "remove from favorites"),
|
||||
(["/w"], nil, "see who's online")
|
||||
]
|
||||
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/fav"], "<nickname>", "add to favorites"),
|
||||
(["/unfav"], "<nickname>", "remove from favorites")
|
||||
]
|
||||
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
||||
|
||||
// Build the display
|
||||
let allCommands = commandInfo
|
||||
@@ -788,25 +833,18 @@ struct ContentView: View {
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
let commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/fav", "add to favorites"),
|
||||
("/help", "show this help"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/unfav", "remove from favorites"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
@@ -888,15 +926,6 @@ struct ContentView: View {
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
// Show QR in mesh on all platforms
|
||||
if case .mesh = locationManager.selectedChannel {
|
||||
Button(action: { showVerifySheet = true }) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Verification: show my QR or scan a friend")
|
||||
}
|
||||
}
|
||||
.frame(height: 44) // Match header height
|
||||
.padding(.horizontal, 12)
|
||||
@@ -909,34 +938,53 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// People section
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if case .location = locationManager.selectedChannel {
|
||||
GeohashPeopleList(viewModel: viewModel,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPerson: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
MeshPeerList(viewModel: viewModel,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPeer: { peerID in
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
},
|
||||
onToggleFavorite: { peerID in
|
||||
viewModel.toggleFavorite(peerID: peerID)
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
viewModel.showFingerprint(for: peerID)
|
||||
})
|
||||
}
|
||||
#if os(iOS)
|
||||
if case .location = locationManager.selectedChannel {
|
||||
GeohashPeopleList(viewModel: viewModel,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPerson: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
MeshPeerList(viewModel: viewModel,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPeer: { peerID in
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
},
|
||||
onToggleFavorite: { peerID in
|
||||
viewModel.toggleFavorite(peerID: peerID)
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
viewModel.showFingerprint(for: peerID)
|
||||
})
|
||||
}
|
||||
#else
|
||||
MeshPeerList(viewModel: viewModel,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPeer: { peerID in
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
},
|
||||
onToggleFavorite: { peerID in
|
||||
viewModel.toggleFavorite(peerID: peerID)
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
viewModel.showFingerprint(for: peerID)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
|
||||
@@ -1026,11 +1074,13 @@ struct ContentView: View {
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||
#if os(iOS)
|
||||
// Compute channel-aware people count and color for toolbar
|
||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationManager.selectedChannel {
|
||||
case .location:
|
||||
let n = viewModel.geohashPeople.count
|
||||
// Use standard green (dark: system green; light: custom darker green)
|
||||
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
return (n, n > 0 ? standardGreen : Color.secondary)
|
||||
case .mesh:
|
||||
@@ -1040,11 +1090,13 @@ struct ContentView: View {
|
||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isMutualFavorite { counts.others += 1 }
|
||||
}
|
||||
// Darker, more neutral blue (less purple hue)
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
||||
return (counts.others, color)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
private var mainHeaderView: some View {
|
||||
@@ -1091,6 +1143,7 @@ struct ContentView: View {
|
||||
|
||||
// Channel badge + dynamic spacing + people counter
|
||||
// Precompute header count and color outside the ViewBuilder expressions
|
||||
#if os(iOS)
|
||||
let cc = channelPeopleCountAndColor()
|
||||
let headerCountColor: Color = cc.1
|
||||
let headerOtherPeersCount: Int = {
|
||||
@@ -1099,11 +1152,24 @@ struct ContentView: View {
|
||||
}
|
||||
return cc.0
|
||||
}()
|
||||
#else
|
||||
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||
let isMeshConnected = peer.isConnected
|
||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isMutualFavorite { counts.others += 1 }
|
||||
}
|
||||
let headerOtherPeersCount = peerCounts.others
|
||||
// Darker, more neutral blue (less purple hue)
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
||||
#endif
|
||||
|
||||
HStack(spacing: 10) {
|
||||
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
||||
|
||||
// Unread indicator (now shown on iOS and macOS)
|
||||
// Unread indicator
|
||||
#if os(iOS)
|
||||
if viewModel.hasAnyUnreadMessages {
|
||||
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
@@ -1138,6 +1204,7 @@ struct ContentView: View {
|
||||
.accessibilityLabel("location channels")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
HStack(spacing: 4) {
|
||||
// People icon with count
|
||||
@@ -1149,8 +1216,6 @@ struct ContentView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(headerCountColor)
|
||||
|
||||
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
@@ -1158,16 +1223,14 @@ struct ContentView: View {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
VerificationSheetView(isPresented: $showVerifySheet)
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||
}
|
||||
#endif
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
@@ -1203,11 +1266,13 @@ struct ContentView: View {
|
||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||
let privatePeerNick: String = {
|
||||
if privatePeerID.hasPrefix("nostr_") {
|
||||
#if os(iOS)
|
||||
// Build geohash DM header: "#<ghash>/@name#abcd"
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||
return "#\(ch.geohash)/@\(disp)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return peer?.displayName ??
|
||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||
@@ -1372,7 +1437,23 @@ private struct PaymentChipView: View {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
||||
struct MessageContentView: View {
|
||||
let message: BitchatMessage
|
||||
let viewModel: ChatViewModel
|
||||
let colorScheme: ColorScheme
|
||||
let isMentioned: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(message.content)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
// buildTextSegments removed: content is rendered plain.
|
||||
}
|
||||
|
||||
// Delivery status indicator view
|
||||
struct DeliveryStatusView: View {
|
||||
|
||||
@@ -32,9 +32,8 @@ struct FingerprintView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Button("DONE") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
@@ -166,20 +165,6 @@ struct FingerprintView: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Button(action: {
|
||||
viewModel.unverifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("REMOVE VERIFICATION")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.red)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct GeohashPeopleList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let textColor: Color
|
||||
@@ -28,12 +29,16 @@ struct GeohashPeopleList: View {
|
||||
let people = viewModel.visibleGeohashPeople()
|
||||
let currentIDs = people.map { $0.id }
|
||||
|
||||
#if os(iOS)
|
||||
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
|
||||
let isTeleportedID: (String) -> Bool = { id in
|
||||
if teleportedSet.contains(id.lowercased()) { return true }
|
||||
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
|
||||
return false
|
||||
}
|
||||
#else
|
||||
let isTeleportedID: (String) -> Bool = { _ in false }
|
||||
#endif
|
||||
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let nonTele = displayIDs.filter { !isTeleportedID($0) }
|
||||
@@ -47,7 +52,11 @@ struct GeohashPeopleList: View {
|
||||
let person = personByID[pid]!
|
||||
HStack(spacing: 4) {
|
||||
let isMe = (person.id == myHex)
|
||||
#if os(iOS)
|
||||
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
||||
#else
|
||||
let teleported = false
|
||||
#endif
|
||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||
let rowColor: Color = isMe ? .orange : assignedColor
|
||||
@@ -118,8 +127,10 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Helper to split a trailing #abcd suffix
|
||||
#if os(iOS)
|
||||
private func splitSuffix(from name: String) -> (String, String) {
|
||||
guard name.count >= 5 else { return (name, "") }
|
||||
let suffix = String(name.suffix(5))
|
||||
@@ -131,3 +142,4 @@ private func splitSuffix(from name: String) -> (String, String) {
|
||||
}
|
||||
return (name, "")
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@@ -40,7 +37,11 @@ struct LocationChannelsSheet: View {
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
Button("open settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -53,7 +54,6 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -61,21 +61,8 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button("close") { isPresented = false }
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Refresh channels when opening
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
@@ -141,12 +128,10 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.keyboardType(.asciiCapable)
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
@@ -198,7 +183,9 @@ struct LocationChannelsSheet: View {
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("remove location access")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
@@ -356,7 +343,7 @@ extension LocationChannelsSheet {
|
||||
}()
|
||||
|
||||
let usesMetric: Bool = {
|
||||
if #available(iOS 16.0, macOS 13.0, *) {
|
||||
if #available(iOS 16.0, *) {
|
||||
return Locale.current.measurementSystem == .metric
|
||||
} else {
|
||||
return Locale.current.usesMetricSystem
|
||||
@@ -379,7 +366,7 @@ extension LocationChannelsSheet {
|
||||
|
||||
private func bluetoothRangeString() -> String {
|
||||
let usesMetric: Bool = {
|
||||
if #available(iOS 16.0, macOS 13.0, *) {
|
||||
if #available(iOS 16.0, *) {
|
||||
return Locale.current.measurementSystem == .metric
|
||||
} else {
|
||||
return Locale.current.usesMetricSystem
|
||||
@@ -403,17 +390,4 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Settings helper
|
||||
private func openSystemLocationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -44,24 +44,9 @@ struct MeshPeerList: View {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isConnected {
|
||||
// Mesh-connected peer: radio icon
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
|
||||
} else {
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
@@ -85,27 +70,10 @@ struct MeshPeerList: View {
|
||||
.help("Blocked")
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
if peer.isConnected {
|
||||
if let icon = item.enc.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
// Offline: prefer showing verified badge from persisted fingerprints
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if let icon = item.enc.icon {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
if let icon = item.enc.icon, !isMe {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Placeholder view to display the user's verification QR payload as text.
|
||||
struct MyQRView: View {
|
||||
let qrString: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("scan to verify me")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
.accessibilityLabel("verification qr code")
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(8)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// Render a QR code image for a given string using CoreImage
|
||||
struct QRCodeImage: View {
|
||||
let data: String
|
||||
let size: CGFloat
|
||||
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = generateImage() {
|
||||
ImageWrapper(image: image)
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text("qr unavailable")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateImage() -> CGImage? {
|
||||
let inputData = Data(data.utf8)
|
||||
filter.message = inputData
|
||||
filter.correctionLevel = "M"
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let scale = max(1, Int(size / 32))
|
||||
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
||||
return context.createCGImage(transformed, from: transformed.extent)
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific wrapper to display CGImage in SwiftUI
|
||||
struct ImageWrapper: View {
|
||||
let image: CGImage
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
let ui = UIImage(cgImage: image)
|
||||
return Image(uiImage: ui)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
#else
|
||||
let ns = NSImage(cgImage: image, size: .zero)
|
||||
return Image(nsImage: ns)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
var isActive: Bool = true
|
||||
@State private var input = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS)
|
||||
CameraScannerView(isActive: isActive) { code in
|
||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
if !ok { /* already pending; continue scanning */ }
|
||||
lastValid = code
|
||||
} else {
|
||||
// ignore invalid reads; continue scanning
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
Text("paste qr content to validate:")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
Button("validate") {
|
||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||
} else {
|
||||
result = "invalid or expired qr payload"
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
#endif
|
||||
// No status text under camera per design
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import AVFoundation
|
||||
|
||||
struct CameraScannerView: UIViewRepresentable {
|
||||
typealias UIViewType = PreviewView
|
||||
var isActive: Bool
|
||||
var onCode: (String) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||
context.coordinator.setActive(isActive)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||
context.coordinator.setActive(isActive)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private var onCode: ((String) -> Void)?
|
||||
private weak var owner: PreviewView?
|
||||
private let session = AVCaptureSession()
|
||||
private var isRunning = false
|
||||
private var permissionGranted = false
|
||||
private var desiredActive = false
|
||||
|
||||
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||
self.owner = sessionOwner
|
||||
self.onCode = onCode
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||
output.metadataObjectTypes = [.qr]
|
||||
}
|
||||
session.commitConfiguration()
|
||||
sessionOwner.videoPreviewLayer.session = session
|
||||
// Request permission and start
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
self.permissionGranted = granted
|
||||
if granted && self.desiredActive && !self.isRunning {
|
||||
self.setActive(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
desiredActive = active
|
||||
guard permissionGranted else { return }
|
||||
if active && !isRunning {
|
||||
isRunning = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !self.session.isRunning { self.session.startRunning() }
|
||||
}
|
||||
} else if !active && isRunning {
|
||||
isRunning = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
for obj in metadataObjects {
|
||||
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||
m.type == .qr,
|
||||
let str = m.stringValue else { continue }
|
||||
onCode?(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
videoPreviewLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Combined sheet: shows my QR by default with a button to scan instead
|
||||
struct VerificationSheetView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@State private var showingScanner = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
|
||||
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private func myQRString() -> String {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("VERIFY")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingScanner = false
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content area
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("scan a friend's qr")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
#if os(iOS)
|
||||
QRScanView(isActive: showingScanner)
|
||||
.environmentObject(viewModel)
|
||||
.frame(height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
QRScanView()
|
||||
.environmentObject(viewModel)
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
let qr = myQRString()
|
||||
MyQRView(qrString: qr)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
// Centered controls moved up
|
||||
VStack(spacing: 10) {
|
||||
if showingScanner {
|
||||
Button(action: { showingScanner = false }) {
|
||||
Label("show my qr", systemImage: "qrcode")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(action: { showingScanner = true }) {
|
||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
}
|
||||
|
||||
// Optional: Remove verification for selected peer (if verified)
|
||||
if let pid = viewModel.selectedPrivateChatPeer,
|
||||
let fp = viewModel.getFingerprint(for: pid),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
Label("remove verification", systemImage: "minus.circle")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onDisappear { showingScanner = false }
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -7,160 +7,160 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Modern share extension using UIKit + UTTypes.
|
||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||
final class ShareViewController: UIViewController {
|
||||
private let statusLabel: UILabel = {
|
||||
let l = UILabel()
|
||||
l.translatesAutoresizingMaskIntoConstraints = false
|
||||
l.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
l.textAlignment = .center
|
||||
l.numberOfLines = 0
|
||||
l.textColor = .label
|
||||
return l
|
||||
}()
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.addSubview(statusLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
|
||||
processShare()
|
||||
// Set placeholder text
|
||||
placeholder = "Share to bitchat..."
|
||||
// Set character limit (optional)
|
||||
charactersRemaining = 500
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
private func processShare() {
|
||||
guard let ctx = self.extensionContext,
|
||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||
finishWithMessage("Nothing to share")
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
// Validate that we have text content or attachments
|
||||
if let text = contentText, !text.isEmpty {
|
||||
return true
|
||||
}
|
||||
// Check if we have attachments
|
||||
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments,
|
||||
!attachments.isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Try content from attributed text first (Safari often passes URL here)
|
||||
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
|
||||
saveAndFinish(url: url, title: item.attributedTitle?.string)
|
||||
return
|
||||
}
|
||||
|
||||
// Scan attachments for URL/text
|
||||
let providers = item.attachments ?? []
|
||||
if providers.isEmpty {
|
||||
// Fallback: use attributed title as plain text
|
||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||
saveAndFinish(text: title)
|
||||
} else {
|
||||
finishWithMessage("No shareable content")
|
||||
|
||||
|
||||
// Get the page title from the compose view or extension item
|
||||
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
|
||||
|
||||
var foundURL: URL? = nil
|
||||
let group = DispatchGroup()
|
||||
|
||||
// IMPORTANT: Check if the NSExtensionItem itself has a URL
|
||||
// Safari often provides the URL as an attributedString with a link
|
||||
if let attributedText = extensionItem.attributedContentText {
|
||||
let text = attributedText.string
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
||||
if let firstMatch = matches?.first, let url = firstMatch.url {
|
||||
foundURL = url
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Load URL or text asynchronously
|
||||
loadFirstURL(from: providers) { [weak self] url in
|
||||
guard let self = self else { return }
|
||||
if let url = url {
|
||||
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.loadFirstPlainText(from: providers) { text in
|
||||
if let t = text, !t.isEmpty {
|
||||
// Treat as URL if parseable http(s), else plain text
|
||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.saveAndFinish(text: t)
|
||||
|
||||
// Only check attachments if we haven't found a URL yet
|
||||
if foundURL == nil {
|
||||
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
|
||||
|
||||
// Try multiple URL type identifiers that Safari might use
|
||||
let urlTypes = [
|
||||
UTType.url.identifier,
|
||||
"public.url",
|
||||
"public.file-url"
|
||||
]
|
||||
|
||||
for urlType in urlTypes {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
|
||||
group.enter()
|
||||
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
|
||||
defer { group.leave() }
|
||||
|
||||
if let url = item as? URL {
|
||||
foundURL = url
|
||||
} else if let data = item as? Data,
|
||||
let urlString = String(data: data, encoding: .utf8),
|
||||
let url = URL(string: urlString) {
|
||||
foundURL = url
|
||||
} else if let string = item as? String,
|
||||
let url = URL(string: string) {
|
||||
foundURL = url
|
||||
}
|
||||
}
|
||||
break // Found a URL type, no need to check other types
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for plain text that might be a URL
|
||||
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
group.enter()
|
||||
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
|
||||
defer { group.leave() }
|
||||
|
||||
if let text = item as? String {
|
||||
// Check if the text is actually a URL
|
||||
if let url = URL(string: text),
|
||||
(url.scheme == "http" || url.scheme == "https") {
|
||||
foundURL = url
|
||||
}
|
||||
} else {
|
||||
self.finishWithMessage("No shareable content")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detectURL(in text: String) -> URL? {
|
||||
guard !text.isEmpty else { return nil }
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(location: 0, length: (text as NSString).length)
|
||||
let match = detector?.matches(in: text, options: [], range: range).first
|
||||
return match?.url
|
||||
}
|
||||
|
||||
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
||||
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
||||
let grp = DispatchGroup()
|
||||
var found: URL?
|
||||
|
||||
for p in providers where found == nil {
|
||||
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
|
||||
grp.enter()
|
||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
||||
defer { grp.leave() }
|
||||
if let u = item as? URL { found = u; return }
|
||||
if let s = item as? String, let u = URL(string: s) { found = u; return }
|
||||
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
|
||||
} // End of if foundURL == nil
|
||||
|
||||
// Process after all checks complete
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
if let url = foundURL {
|
||||
// We have a URL! Create the JSON data
|
||||
let urlData: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": pageTitle ?? url.host ?? "Shared Link"
|
||||
]
|
||||
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
self?.saveToSharedDefaults(content: jsonString, type: "url")
|
||||
}
|
||||
break
|
||||
} else if let title = pageTitle, !title.isEmpty {
|
||||
// No URL found, just share the text
|
||||
self?.saveToSharedDefaults(content: title, type: "text")
|
||||
}
|
||||
}
|
||||
grp.notify(queue: .main) { completion(found) }
|
||||
}
|
||||
|
||||
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
||||
let id = UTType.plainText.identifier
|
||||
let grp = DispatchGroup()
|
||||
var text: String?
|
||||
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
|
||||
grp.enter()
|
||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
||||
defer { grp.leave() }
|
||||
if let s = item as? String { text = s }
|
||||
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
|
||||
}
|
||||
break
|
||||
}
|
||||
grp.notify(queue: .main) { completion(text) }
|
||||
}
|
||||
|
||||
// MARK: - Save + Finish
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? "Shared Link"
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage("✓ Shared link to bitchat")
|
||||
} else {
|
||||
finishWithMessage("Failed to encode link")
|
||||
|
||||
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndFinish(text: String) {
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage("✓ Shared text to bitchat")
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
// No configuration items needed
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
||||
// Use app groups to share data between extension and main app
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||
return
|
||||
}
|
||||
|
||||
userDefaults.set(content, forKey: "sharedContent")
|
||||
userDefaults.set(type, forKey: "sharedContentType")
|
||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||
userDefaults.synchronize()
|
||||
|
||||
|
||||
// Force open the main app
|
||||
self.openMainApp()
|
||||
}
|
||||
|
||||
private func finishWithMessage(_ msg: String) {
|
||||
statusLabel.text = msg
|
||||
// Complete shortly after showing status
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
|
||||
private func openMainApp() {
|
||||
// Share extensions cannot directly open the containing app
|
||||
// The app will check for shared content when it becomes active
|
||||
// Show success feedback to user
|
||||
DispatchQueue.main.async {
|
||||
self.textView.text = "✓ Shared to bitchat"
|
||||
self.textView.isEditable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,8 @@ final class NostrProtocolTests: XCTestCase {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
#if DEBUG
|
||||
print("Sender pubkey: \(sender.publicKeyHex)")
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
#endif
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
@@ -36,10 +34,8 @@ final class NostrProtocolTests: XCTestCase {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
#endif
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -56,9 +52,7 @@ final class NostrProtocolTests: XCTestCase {
|
||||
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
||||
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
||||
|
||||
#if DEBUG
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
||||
@@ -81,10 +75,8 @@ final class NostrProtocolTests: XCTestCase {
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
||||
#if DEBUG
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
#endif
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -117,114 +109,7 @@ final class NostrProtocolTests: XCTestCase {
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)) { error in
|
||||
#if DEBUG
|
||||
print("Expected error when decrypting with wrong key: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||
let messageID = "TEST-MSG-DELIVERED-1"
|
||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed delivered ack")
|
||||
return
|
||||
}
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
// Verify sender is correct
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
// Parse BitChat payload
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .delivered:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let messageID = "TEST-MSG-READ-1"
|
||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed read ack")
|
||||
return
|
||||
}
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .readReceipt:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// LegacyTestProtocolTypes.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Minimal legacy protocol types used only by tests to simulate old flows.
|
||||
// These are not part of production code anymore.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ProtocolNack {
|
||||
let originalPacketID: String
|
||||
let nackID: String
|
||||
let senderID: String
|
||||
let receiverID: String
|
||||
let packetType: UInt8
|
||||
let reason: String
|
||||
let errorCode: UInt8
|
||||
|
||||
enum ErrorCode: UInt8 {
|
||||
case unknown = 0
|
||||
case decryptionFailed = 2
|
||||
}
|
||||
|
||||
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
|
||||
self.originalPacketID = originalPacketID
|
||||
self.nackID = UUID().uuidString
|
||||
self.senderID = senderID
|
||||
self.receiverID = receiverID
|
||||
self.packetType = packetType
|
||||
self.reason = reason
|
||||
self.errorCode = errorCode.rawValue
|
||||
}
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
// Tests don't parse the payload; return a compact encoding for completeness
|
||||
var data = Data()
|
||||
data.appendUUID(originalPacketID)
|
||||
data.appendUUID(nackID)
|
||||
data.append(UInt8(packetType))
|
||||
data.append(UInt8(errorCode))
|
||||
data.appendString(reason)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
Command line invocation:
|
||||
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
|
||||
|
||||
Build settings from command line:
|
||||
CODE_SIGNING_ALLOWED = NO
|
||||
ONLY_ACTIVE_ARCH = YES
|
||||
SDKROOT = iphonesimulator18.5
|
||||
|
||||
Resolve Package Graph
|
||||
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
|
||||
Package: swift-secp256k1
|
||||
|
||||
fatalError
|
||||
Reference in New Issue
Block a user